Refactor to use ai/rsc (#253)

This commit is contained in:
Jeremy 2024-03-14 20:00:52 +03:00 committed by GitHub
parent 69ca8fcc22
commit e85ba803dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 2799 additions and 740 deletions

View file

@ -4,13 +4,6 @@ OPENAI_API_KEY=XXXXXXXX
# Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32` # Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32`
AUTH_SECRET=XXXXXXXX AUTH_SECRET=XXXXXXXX
# Create a GitHub OAuth app here: https://github.com/settings/applications/new
# For info on authorization callback URL visit here: https://authjs.dev/reference/core/providers_github#callback-url
AUTH_GITHUB_ID=XXXXXXXX
AUTH_GITHUB_SECRET=XXXXXXXX
# Support OAuth login on preview deployments, see: https://authjs.dev/guides/basics/deployment#securing-a-preview-deployment
# Set the following only when deployed. In this example, we can reuse the same OAuth app, but if you are storing users, we recommend using a different OAuth app for development/production so that you don't mix your test and production user base.
# AUTH_REDIRECT_PROXY_URL=https://YOURAPP.vercel.app/api/auth
# Instructions to create kv database here: https://vercel.com/docs/storage/vercel-kv/quickstart and # Instructions to create kv database here: https://vercel.com/docs/storage/vercel-kv/quickstart and
KV_URL=XXXXXXXX KV_URL=XXXXXXXX
@ -18,3 +11,12 @@ KV_REST_API_URL=XXXXXXXX
KV_REST_API_TOKEN=XXXXXXXX KV_REST_API_TOKEN=XXXXXXXX
KV_REST_API_READ_ONLY_TOKEN=XXXXXXXX KV_REST_API_READ_ONLY_TOKEN=XXXXXXXX
# Instructions to create postgres database here: https://vercel.com/docs/storage/vercel-postgres/quickstart and
POSTGRES_URL=XXXXXXXX
POSTGRES_PRISMA_URL=XXXXXXXX
POSTGRES_URL_NO_SSL=XXXXXXXX
POSTGRES_URL_NON_POOLING=XXXXXXXX
POSTGRES_USER=XXXXXXXX
POSTGRES_HOST=XXXXXXXX
POSTGRES_PASSWORD=XXXXXXXX
POSTGRES_DATABASE=XXXXXXXX

View file

@ -26,7 +26,7 @@
- Styling with [Tailwind CSS](https://tailwindcss.com) - Styling with [Tailwind CSS](https://tailwindcss.com)
- [Radix UI](https://radix-ui.com) for headless component primitives - [Radix UI](https://radix-ui.com) for headless component primitives
- Icons from [Phosphor Icons](https://phosphoricons.com) - Icons from [Phosphor Icons](https://phosphoricons.com)
- Chat History, rate limiting, and session storage with [Vercel KV](https://vercel.com/storage/kv) - Chat History, rate limiting, and session storage with [Vercel KV](https://vercel.com/storage/kv) and [Vercel Postgres](https://vercel.com/docs/storage/vercel-postgres)
- [NextAuth.js](https://github.com/nextauthjs/next-auth) for authentication - [NextAuth.js](https://github.com/nextauthjs/next-auth) for authentication
## Model Providers ## Model Providers
@ -37,7 +37,7 @@ This template ships with OpenAI `gpt-3.5-turbo` as the default. However, thanks
You can deploy your own version of the Next.js AI Chatbot to Vercel with one click: You can deploy your own version of the Next.js AI Chatbot to Vercel with one click:
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?demo-title=Next.js+Chat&demo-description=A+full-featured%2C+hackable+Next.js+AI+chatbot+built+by+Vercel+Labs&demo-url=https%3A%2F%2Fchat.vercel.ai%2F&demo-image=%2F%2Fimages.ctfassets.net%2Fe5382hct74si%2F4aVPvWuTmBvzM5cEdRdqeW%2F4234f9baf160f68ffb385a43c3527645%2FCleanShot_2023-06-16_at_17.09.21.png&project-name=Next.js+Chat&repository-name=nextjs-chat&repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fai-chatbot&from=templates&skippable-integrations=1&env=OPENAI_API_KEY%2CAUTH_GITHUB_ID%2CAUTH_GITHUB_SECRET%2CAUTH_SECRET&envDescription=How+to+get+these+env+vars&envLink=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fai-chatbot%2Fblob%2Fmain%2F.env.example&teamCreateStatus=hidden&stores=[{"type":"kv"}]) [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?demo-title=Next.js+Chat&demo-description=A+full-featured%2C+hackable+Next.js+AI+chatbot+built+by+Vercel+Labs&demo-url=https%3A%2F%2Fchat.vercel.ai%2F&demo-image=%2F%2Fimages.ctfassets.net%2Fe5382hct74si%2F4aVPvWuTmBvzM5cEdRdqeW%2F4234f9baf160f68ffb385a43c3527645%2FCleanShot_2023-06-16_at_17.09.21.png&project-name=Next.js+Chat&repository-name=nextjs-chat&repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fai-chatbot&from=templates&skippable-integrations=1&env=OPENAI_API_KEY%2CAUTH_SECRET&envDescription=How+to+get+these+env+vars&envLink=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fai-chatbot%2Fblob%2Fmain%2F.env.example&teamCreateStatus=hidden&stores=[{"type":"kv"},{"type":"postgres"}])
## Creating a KV Database Instance ## Creating a KV Database Instance
@ -45,6 +45,12 @@ Follow the steps outlined in the [quick start guide](https://vercel.com/docs/sto
Remember to update your environment variables (`KV_URL`, `KV_REST_API_URL`, `KV_REST_API_TOKEN`, `KV_REST_API_READ_ONLY_TOKEN`) in the `.env` file with the appropriate credentials provided during the KV database setup. Remember to update your environment variables (`KV_URL`, `KV_REST_API_URL`, `KV_REST_API_TOKEN`, `KV_REST_API_READ_ONLY_TOKEN`) in the `.env` file with the appropriate credentials provided during the KV database setup.
## Create a Postgres Database Instance
Similarly, follow the steps outline in the [quick start guide](https://vercel.com/docs/storage/vercel-postgres/quickstart) provided by Vercel. This guide will assist you in creating and configuring your Postgres database instance on Vercel, enabling your application to interact with it.
Remember to update your environment variables (`POSTGRES_URL`, `POSTGRES_PRISMA_URL`, `POSTGRES_URL_NO_SSL`, `POSTGRES_URL_NON_POOLING`, `POSTGRES_USER`, `POSTGRES_HOST`, `POSTGRES_PASSWORD`, `POSTGRES_DATABASE`) in the `.env` file with the appropriate credentials provided during the Postgres database setup.
## Running locally ## Running locally
You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js AI Chatbot. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/projects/environment-variables) for this, but a `.env` file is all that is necessary. You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js AI Chatbot. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/projects/environment-variables) for this, but a `.env` file is all that is necessary.
@ -57,6 +63,7 @@ You will need to use the environment variables [defined in `.env.example`](.env.
```bash ```bash
pnpm install pnpm install
pnpm seed
pnpm dev pnpm dev
``` ```

View file

@ -2,8 +2,10 @@ import { type Metadata } from 'next'
import { notFound, redirect } from 'next/navigation' import { notFound, redirect } from 'next/navigation'
import { auth } from '@/auth' import { auth } from '@/auth'
import { getChat } from '@/app/actions' import { getChat, getMissingKeys } from '@/app/actions'
import { Chat } from '@/components/chat' import { Chat } from '@/components/chat'
import { AI } from '@/lib/chat/actions'
import { Session } from '@/lib/types'
export interface ChatPageProps { export interface ChatPageProps {
params: { params: {
@ -27,21 +29,32 @@ export async function generateMetadata({
} }
export default async function ChatPage({ params }: ChatPageProps) { export default async function ChatPage({ params }: ChatPageProps) {
const session = await auth() const session = (await auth()) as Session
const missingKeys = await getMissingKeys()
if (!session?.user) { if (!session?.user) {
redirect(`/sign-in?next=/chat/${params.id}`) redirect(`/login?next=/chat/${params.id}`)
} }
const chat = await getChat(params.id, session.user.id) const userId = session.user.id as string
const chat = await getChat(params.id, userId)
if (!chat) { if (!chat) {
notFound() redirect('/')
} }
if (chat?.userId !== session?.user?.id) { if (chat?.userId !== session?.user?.id) {
notFound() notFound()
} }
return <Chat id={chat.id} initialMessages={chat.messages} /> return (
<AI initialAIState={{ chatId: chat.id, messages: chat.messages }}>
<Chat
id={chat.id}
session={session}
initialMessages={chat.messages}
missingKeys={missingKeys}
/>
</AI>
)
} }

View file

@ -1,8 +1,22 @@
import { nanoid } from '@/lib/utils' import { nanoid } from '@/lib/utils'
import { Chat } from '@/components/chat' import { Chat } from '@/components/chat'
import { AI } from '@/lib/chat/actions'
import { auth } from '@/auth'
import { Session } from '@/lib/types'
import { getMissingKeys } from '../actions'
export default function IndexPage() { export const metadata = {
const id = nanoid() title: 'Next.js AI Chatbot'
}
return <Chat id={id} />
export default async function IndexPage() {
const id = nanoid()
const session = (await auth()) as Session
const missingKeys = await getMissingKeys()
return (
<AI initialAIState={{ chatId: id, messages: [] }}>
<Chat id={id} session={session} missingKeys={missingKeys} />
</AI>
)
} }

View file

@ -127,3 +127,30 @@ export async function shareChat(id: string) {
return payload return payload
} }
export async function saveChat(chat: Chat) {
const session = await auth()
if (session && session.user) {
const pipeline = kv.pipeline()
pipeline.hmset(`chat:${chat.id}`, chat)
pipeline.zadd(`user:chat:${chat.userId}`, {
score: Date.now(),
member: `chat:${chat.id}`
})
await pipeline.exec()
} else {
return
}
}
export async function refreshHistory(path: string) {
redirect(path)
}
export async function getMissingKeys() {
const keysRequired = ['OPENAI_API_KEY']
return keysRequired
.map(key => (process.env[key] ? '' : key))
.filter(key => key !== '')
}

View file

@ -1,2 +0,0 @@
export { GET, POST } from '@/auth'
export const runtime = 'edge'

View file

@ -1,65 +0,0 @@
import { kv } from '@vercel/kv'
import { OpenAIStream, StreamingTextResponse } from 'ai'
import OpenAI from 'openai'
import { auth } from '@/auth'
import { nanoid } from '@/lib/utils'
export const runtime = 'edge'
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
})
export async function POST(req: Request) {
const json = await req.json()
const { messages, previewToken } = json
const userId = (await auth())?.user.id
if (!userId) {
return new Response('Unauthorized', {
status: 401
})
}
if (previewToken) {
openai.apiKey = previewToken
}
const res = await openai.chat.completions.create({
model: 'gpt-3.5-turbo',
messages,
temperature: 0.7,
stream: true
})
const stream = OpenAIStream(res, {
async onCompletion(completion) {
const title = json.messages[0].content.substring(0, 100)
const id = json.id ?? nanoid()
const createdAt = Date.now()
const path = `/chat/${id}`
const payload = {
id,
title,
userId,
createdAt,
path,
messages: [
...messages,
{
content: completion,
role: 'assistant'
}
]
}
await kv.hmset(`chat:${id}`, payload)
await kv.zadd(`user:chat:${userId}`, {
score: createdAt,
member: `chat:${id}`
})
}
})
return new StreamingTextResponse(stream)
}

View file

@ -7,17 +7,11 @@
--background: 0 0% 100%; --background: 0 0% 100%;
--foreground: 240 10% 3.9%; --foreground: 240 10% 3.9%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--card: 0 0% 100%; --card: 0 0% 100%;
--card-foreground: 240 10% 3.9%; --card-foreground: 240 10% 3.9%;
--border: 240 5.9% 90%; --popover: 0 0% 100%;
--input: 240 5.9% 90%; --popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%; --primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%; --primary-foreground: 0 0% 98%;
@ -25,13 +19,18 @@
--secondary: 240 4.8% 95.9%; --secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%; --secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%; --accent: 240 4.8% 95.9%;
--accent-foreground: ; --accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%; --destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 98%;
--ring: 240 5% 64.9%; --border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--radius: 0.5rem; --radius: 0.5rem;
} }
@ -40,17 +39,11 @@
--background: 240 10% 3.9%; --background: 240 10% 3.9%;
--foreground: 0 0% 98%; --foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--card: 240 10% 3.9%; --card: 240 10% 3.9%;
--card-foreground: 0 0% 98%; --card-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%; --popover: 240 10% 3.9%;
--input: 240 3.7% 15.9%; --popover-foreground: 0 0% 98%;
--primary: 0 0% 98%; --primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%; --primary-foreground: 240 5.9% 10%;
@ -58,13 +51,18 @@
--secondary: 240 3.7% 15.9%; --secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%; --secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%; --accent: 240 3.7% 15.9%;
--accent-foreground: ; --accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%; --destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 85.7% 97.3%; --destructive-foreground: 0 0% 98%;
--ring: 240 3.7% 15.9%; --border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
} }
} }

View file

@ -1,4 +1,3 @@
import { Toaster } from 'react-hot-toast'
import { GeistSans } from 'geist/font/sans' import { GeistSans } from 'geist/font/sans'
import { GeistMono } from 'geist/font/mono' import { GeistMono } from 'geist/font/mono'
@ -7,6 +6,7 @@ import { cn } from '@/lib/utils'
import { TailwindIndicator } from '@/components/tailwind-indicator' import { TailwindIndicator } from '@/components/tailwind-indicator'
import { Providers } from '@/components/providers' import { Providers } from '@/components/providers'
import { Header } from '@/components/header' import { Header } from '@/components/header'
import { Toaster } from '@/components/ui/sonner'
export const metadata = { export const metadata = {
metadataBase: new URL(`https://${process.env.VERCEL_URL}`), metadataBase: new URL(`https://${process.env.VERCEL_URL}`),
@ -43,7 +43,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
GeistMono.variable GeistMono.variable
)} )}
> >
<Toaster /> <Toaster position="top-center" />
<Providers <Providers
attribute="class" attribute="class"
defaultTheme="system" defaultTheme="system"

50
app/login/actions.ts Normal file
View file

@ -0,0 +1,50 @@
'use server'
import { signIn } from '@/auth'
import { AuthResult } from '@/lib/types'
import { AuthError } from 'next-auth'
import { z } from 'zod'
export async function authenticate(
_prevState: AuthResult | undefined,
formData: FormData
) {
try {
const email = formData.get('email')
const password = formData.get('password')
const parsedCredentials = z
.object({
email: z.string().email(),
password: z.string().min(6)
})
.safeParse({
email,
password
})
if (parsedCredentials.success) {
await signIn('credentials', {
email,
password,
redirectTo: '/'
})
} else {
return { type: 'error', message: 'Invalid credentials!' }
}
} catch (error) {
if (error instanceof AuthError) {
switch (error.type) {
case 'CredentialsSignin':
return { type: 'error', message: 'Invalid credentials!' }
default:
return {
type: 'error',
message: 'Something went wrong, please try again!'
}
}
}
throw error
}
}

18
app/login/page.tsx Normal file
View file

@ -0,0 +1,18 @@
import { auth } from '@/auth'
import LoginForm from '@/components/login-form'
import { Session } from '@/lib/types'
import { redirect } from 'next/navigation'
export default async function LoginPage() {
const session = (await auth()) as Session
if (session) {
redirect('/')
}
return (
<main className="flex flex-col p-4">
<LoginForm />
</main>
)
}

View file

@ -1,10 +1,14 @@
import { type Metadata } from 'next' import { type Metadata } from 'next'
import { notFound } from 'next/navigation' import { notFound, redirect } from 'next/navigation'
import { formatDate } from '@/lib/utils' import { formatDate } from '@/lib/utils'
import { getSharedChat } from '@/app/actions' import { getSharedChat } from '@/app/actions'
import { ChatList } from '@/components/chat-list' import { ChatList } from '@/components/chat-list'
import { FooterText } from '@/components/footer' import { FooterText } from '@/components/footer'
import { AI, UIState, getUIStateFromAIState } from '@/lib/chat/actions'
export const runtime = 'edge'
export const preferredRegion = 'home'
interface SharePageProps { interface SharePageProps {
params: { params: {
@ -29,11 +33,13 @@ export default async function SharePage({ params }: SharePageProps) {
notFound() notFound()
} }
const uiState: UIState = getUIStateFromAIState(chat)
return ( return (
<> <>
<div className="flex-1 space-y-6"> <div className="flex-1 space-y-6">
<div className="px-4 py-6 border-b bg-background md:px-6 md:py-8"> <div className="border-b bg-background px-4 py-6 md:px-6 md:py-8">
<div className="max-w-2xl mx-auto md:px-6"> <div className="mx-auto max-w-2xl">
<div className="space-y-1 md:-mx-8"> <div className="space-y-1 md:-mx-8">
<h1 className="text-2xl font-bold">{chat.title}</h1> <h1 className="text-2xl font-bold">{chat.title}</h1>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
@ -42,7 +48,9 @@ export default async function SharePage({ params }: SharePageProps) {
</div> </div>
</div> </div>
</div> </div>
<ChatList messages={chat.messages} /> <AI>
<ChatList messages={uiState} />
</AI>
</div> </div>
<FooterText className="py-8" /> <FooterText className="py-8" />
</> </>

View file

@ -1,17 +0,0 @@
import { auth } from '@/auth'
import { LoginButton } from '@/components/login-button'
import { redirect } from 'next/navigation'
export default async function SignInPage() {
const session = await auth()
// redirect to home if user is already logged in
if (session?.user) {
redirect('/')
}
return (
<div className="flex h-[calc(100vh-theme(spacing.16))] items-center justify-center py-10">
<LoginButton />
</div>
)
}

75
app/signup/actions.ts Normal file
View file

@ -0,0 +1,75 @@
'use server'
import { signIn } from '@/auth'
import { db } from '@vercel/postgres'
import { getStringFromBuffer } from '@/lib/utils'
import { z } from 'zod'
import { AuthResult } from '@/lib/types'
export async function signup(
_prevState: AuthResult | undefined,
formData: FormData
) {
const email = formData.get('email') as string
const password = formData.get('password') as string
const parsedCredentials = z
.object({
email: z.string().email(),
password: z.string().min(6)
})
.safeParse({
email,
password
})
if (parsedCredentials.success) {
const salt = crypto.randomUUID()
const encoder = new TextEncoder()
const saltedPassword = encoder.encode(password + salt)
const hashedPasswordBuffer = await crypto.subtle.digest(
'SHA-256',
saltedPassword
)
const hashedPassword = getStringFromBuffer(hashedPasswordBuffer)
const client = await db.connect()
try {
await client.sql`
INSERT INTO users (email, password, salt)
VALUES (${email}, ${hashedPassword}, ${salt})
ON CONFLICT (id) DO NOTHING;
`
await signIn('credentials', {
email,
password,
redirect: false
})
return { type: 'success', message: 'Account created!' }
} catch (error) {
const { message } = error as Error
if (
message.startsWith('duplicate key value violates unique constraint')
) {
return { type: 'error', message: 'User already exists! Please log in.' }
} else {
return {
type: 'error',
message: 'Something went wrong! Please try again.'
}
}
} finally {
client.release()
}
} else {
return {
type: 'error',
message: 'Invalid entries, please try again!'
}
}
}

18
app/signup/page.tsx Normal file
View file

@ -0,0 +1,18 @@
import { auth } from '@/auth'
import SignupForm from '@/components/signup-form'
import { Session } from '@/lib/types'
import { redirect } from 'next/navigation'
export default async function SignupPage() {
const session = (await auth()) as Session
if (session) {
redirect('/')
}
return (
<main className="flex flex-col p-4">
<SignupForm />
</main>
)
}

42
auth.config.ts Normal file
View file

@ -0,0 +1,42 @@
import type { NextAuthConfig } from 'next-auth'
export const authConfig = {
secret: process.env.AUTH_SECRET,
pages: {
signIn: '/login',
newUser: '/signup'
},
callbacks: {
async authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user
const isOnLoginPage = nextUrl.pathname.startsWith('/login')
const isOnSignupPage = nextUrl.pathname.startsWith('/signup')
if (isLoggedIn) {
if (isOnLoginPage || isOnSignupPage) {
return Response.redirect(new URL('/', nextUrl))
}
}
return true
},
async jwt({ token, user }) {
if (user) {
token = { ...token, id: user.id }
}
return token
},
async session({ session, token }) {
if (token) {
const { id } = token as { id: string }
const { user } = session
session = { ...session, user: { ...user, id } }
}
return session
}
},
providers: []
} satisfies NextAuthConfig

83
auth.ts
View file

@ -1,39 +1,62 @@
import NextAuth, { type DefaultSession } from 'next-auth' import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github' import Credentials from 'next-auth/providers/credentials'
import { authConfig } from './auth.config'
import { z } from 'zod'
import { sql } from '@vercel/postgres'
import { getStringFromBuffer } from './lib/utils'
declare module 'next-auth' { interface User {
interface Session {
user: {
/** The user's id. */
id: string id: string
} & DefaultSession['user'] name: string
email: string
password: string
salt: string
}
async function getUser(email: string): Promise<User | undefined> {
try {
const user = await sql<User>`SELECT * FROM users WHERE email=${email}`
return user.rows[0]
} catch (error) {
throw new Error('Failed to fetch user.')
} }
} }
export const { export const { auth, signIn, signOut } = NextAuth({
handlers: { GET, POST }, ...authConfig,
auth providers: [
} = NextAuth({ Credentials({
providers: [GitHub], async authorize(credentials) {
callbacks: { const parsedCredentials = z
jwt({ token, profile }) { .object({
if (profile) { email: z.string().email(),
token.id = profile.id password: z.string().min(6)
token.image = profile.avatar_url || profile.picture })
.safeParse(credentials)
if (parsedCredentials.success) {
const { email, password } = parsedCredentials.data
const user = await getUser(email)
if (!user) return null
const encoder = new TextEncoder()
const saltedPassword = encoder.encode(password + user.salt)
const hashedPasswordBuffer = await crypto.subtle.digest(
'SHA-256',
saltedPassword
)
const hashedPassword = getStringFromBuffer(hashedPasswordBuffer)
if (hashedPassword === user.password) {
return user
} else {
return null
} }
return token
},
session: ({ session, token }) => {
if (session?.user && token?.id) {
session.user.id = String(token.id)
} }
return session
}, return null
authorized({ auth }) {
return !!auth?.user // this ensures there is a logged in user for -every- request
}
},
pages: {
signIn: '/sign-in' // overrides the next-auth default signin page https://authjs.dev/guides/basics/pages
} }
})
]
}) })

17
components.json Normal file
View file

@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

View file

@ -14,7 +14,10 @@ interface ChatHistoryProps {
export async function ChatHistory({ userId }: ChatHistoryProps) { export async function ChatHistory({ userId }: ChatHistoryProps) {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="px-2 my-4"> <div className="flex items-center justify-between p-4">
<h4 className="text-sm font-medium">Chat History</h4>
</div>
<div className="mb-2 px-2">
<Link <Link
href="/" href="/"
className={cn( className={cn(

View file

@ -1,10 +1,8 @@
import { type Message } from 'ai'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { ChatMessage } from '@/components/chat-message' import { UIState } from '@/lib/chat/actions'
export interface ChatList { export interface ChatList {
messages: Message[] messages: UIState
} }
export function ChatList({ messages }: ChatList) { export function ChatList({ messages }: ChatList) {
@ -15,11 +13,9 @@ export function ChatList({ messages }: ChatList) {
return ( return (
<div className="relative mx-auto max-w-2xl px-4"> <div className="relative mx-auto max-w-2xl px-4">
{messages.map((message, index) => ( {messages.map((message, index) => (
<div key={index}> <div key={message.id}>
<ChatMessage message={message} /> {message.display}
{index < messages.length - 1 && ( {index < messages.length - 1 && <Separator className="my-4" />}
<Separator className="my-4 md:my-8" />
)}
</div> </div>
))} ))}
</div> </div>

View file

@ -1,63 +1,96 @@
import * as React from 'react' import * as React from 'react'
import { type UseChatHelpers } from 'ai/react'
import { shareChat } from '@/app/actions' import { shareChat } from '@/app/actions'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { PromptForm } from '@/components/prompt-form' import { PromptForm } from '@/components/prompt-form'
import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom' import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom'
import { IconRefresh, IconShare, IconStop } from '@/components/ui/icons' import { IconShare } from '@/components/ui/icons'
import { FooterText } from '@/components/footer' import { FooterText } from '@/components/footer'
import { ChatShareDialog } from '@/components/chat-share-dialog' import { ChatShareDialog } from '@/components/chat-share-dialog'
import { useAIState, useActions, useUIState } from 'ai/rsc'
import type { AI } from '@/lib/chat/actions'
import { nanoid } from 'nanoid'
import { UserMessage } from './stocks/message'
export interface ChatPanelProps export interface ChatPanelProps {
extends Pick<
UseChatHelpers,
| 'append'
| 'isLoading'
| 'reload'
| 'messages'
| 'stop'
| 'input'
| 'setInput'
> {
id?: string id?: string
title?: string title?: string
input: string
setInput: (value: string) => void
} }
export function ChatPanel({ export function ChatPanel({ id, title, input, setInput }: ChatPanelProps) {
id, const [aiState] = useAIState()
title, const [messages, setMessages] = useUIState<typeof AI>()
isLoading, const { submitUserMessage } = useActions()
stop,
append,
reload,
input,
setInput,
messages
}: ChatPanelProps) {
const [shareDialogOpen, setShareDialogOpen] = React.useState(false) const [shareDialogOpen, setShareDialogOpen] = React.useState(false)
const exampleMessages = [
{
heading: 'Explain the concept',
subheading: 'of a serverless function',
message: `Explain the concept of a serverless function`
},
{
heading: 'What are the benefits',
subheading: 'of using turborepo in my codebase?',
message: 'What are the benefits of using turborepo in my codebase?'
},
{
heading: 'List differences between',
subheading: 'pages and app router in Next.js',
message: `List differences between pages and app router in Next.js`
},
{
heading: 'What is the price',
subheading: `of VRCL in the stock market?`,
message: `What is the price of VRCL in the stock market?`
}
]
return ( return (
<div className="fixed inset-x-0 bottom-0 w-full bg-gradient-to-b from-muted/30 from-0% to-muted/30 to-50% animate-in duration-300 ease-in-out dark:from-background/10 dark:from-10% dark:to-background/80 peer-[[data-state=open]]:group-[]:lg:pl-[250px] peer-[[data-state=open]]:group-[]:xl:pl-[300px]"> <div className="fixed inset-x-0 bottom-0 w-full bg-gradient-to-b from-muted/30 from-0% to-muted/30 to-50% duration-300 ease-in-out animate-in dark:from-background/10 dark:from-10% dark:to-background/80 peer-[[data-state=open]]:group-[]:lg:pl-[250px] peer-[[data-state=open]]:group-[]:xl:pl-[300px]">
<ButtonScrollToBottom /> <ButtonScrollToBottom />
<div className="mx-auto sm:max-w-2xl sm:px-4"> <div className="mx-auto sm:max-w-2xl sm:px-4">
<div className="flex items-center justify-center h-12"> <div className="mb-4 grid grid-cols-2 gap-2 px-4 sm:px-0">
{isLoading ? ( {messages.length === 0 &&
<Button exampleMessages.map((example, index) => (
variant="outline" <div
onClick={() => stop()} key={example.heading}
className="bg-background" className={`cursor-pointer rounded-lg border bg-white p-4 hover:bg-zinc-50 dark:bg-zinc-950 dark:hover:bg-zinc-900 ${
index > 1 && 'hidden md:block'
}`}
onClick={async () => {
setMessages(currentMessages => [
...currentMessages,
{
id: nanoid(),
display: <UserMessage>{example.message}</UserMessage>
}
])
const responseMessage = await submitUserMessage(
example.message
)
setMessages(currentMessages => [
...currentMessages,
responseMessage
])
}}
> >
<IconStop className="mr-2" /> <div className="text-sm font-semibold">{example.heading}</div>
Stop generating <div className="text-sm text-zinc-600">
</Button> {example.subheading}
) : ( </div>
messages?.length >= 2 && ( </div>
))}
</div>
{messages?.length >= 2 ? (
<div className="flex h-12 items-center justify-center">
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button variant="outline" onClick={() => reload()}>
<IconRefresh className="mr-2" />
Regenerate response
</Button>
{id && title ? ( {id && title ? (
<> <>
<Button <Button
@ -75,28 +108,17 @@ export function ChatPanel({
chat={{ chat={{
id, id,
title, title,
messages messages: aiState.messages
}} }}
/> />
</> </>
) : null} ) : null}
</div> </div>
)
)}
</div> </div>
<div className="px-4 py-2 space-y-4 border-t shadow-lg bg-background sm:rounded-t-xl sm:border md:py-4"> ) : null}
<PromptForm
onSubmit={async value => { <div className="space-y-4 border-t bg-background px-4 py-2 shadow-lg sm:rounded-t-xl sm:border md:py-4">
await append({ <PromptForm input={input} setInput={setInput} />
id,
content: value,
role: 'user'
})
}}
input={input}
setInput={setInput}
isLoading={isLoading}
/>
<FooterText className="hidden sm:block" /> <FooterText className="hidden sm:block" />
</div> </div>
</div> </div>

View file

@ -2,7 +2,6 @@
import * as React from 'react' import * as React from 'react'
import { useInView } from 'react-intersection-observer' import { useInView } from 'react-intersection-observer'
import { useAtBottom } from '@/lib/hooks/use-at-bottom' import { useAtBottom } from '@/lib/hooks/use-at-bottom'
interface ChatScrollAnchorProps { interface ChatScrollAnchorProps {
@ -14,7 +13,7 @@ export function ChatScrollAnchor({ trackVisibility }: ChatScrollAnchorProps) {
const { ref, entry, inView } = useInView({ const { ref, entry, inView } = useInView({
trackVisibility, trackVisibility,
delay: 100, delay: 100,
rootMargin: '0px 0px -150px 0px' rootMargin: '0px 0px -125px 0px'
}) })
React.useEffect(() => { React.useEffect(() => {

View file

@ -2,7 +2,7 @@
import * as React from 'react' import * as React from 'react'
import { type DialogProps } from '@radix-ui/react-dialog' import { type DialogProps } from '@radix-ui/react-dialog'
import { toast } from 'react-hot-toast' import { toast } from 'sonner'
import { ServerActionResult, type Chat } from '@/lib/types' import { ServerActionResult, type Chat } from '@/lib/types'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -42,18 +42,7 @@ export function ChatShareDialog({
url.pathname = chat.sharePath url.pathname = chat.sharePath
copyToClipboard(url.toString()) copyToClipboard(url.toString())
onCopy() onCopy()
toast.success('Share link copied to clipboard', { toast.success('Share link copied to clipboard')
style: {
borderRadius: '10px',
background: '#333',
color: '#fff',
fontSize: '14px'
},
iconTheme: {
primary: 'white',
secondary: 'black'
}
})
}, },
[copyToClipboard, onCopy] [copyToClipboard, onCopy]
) )

View file

@ -1,61 +1,60 @@
'use client' 'use client'
import { useChat, type Message } from 'ai/react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { ChatList } from '@/components/chat-list' import { ChatList } from '@/components/chat-list'
import { ChatPanel } from '@/components/chat-panel' import { ChatPanel } from '@/components/chat-panel'
import { EmptyScreen } from '@/components/empty-screen' import { EmptyScreen } from '@/components/empty-screen'
import { ChatScrollAnchor } from '@/components/chat-scroll-anchor' import { ChatScrollAnchor } from '@/components/chat-scroll-anchor'
import { useLocalStorage } from '@/lib/hooks/use-local-storage' import { useLocalStorage } from '@/lib/hooks/use-local-storage'
import { import { useEffect, useState } from 'react'
Dialog, import { useUIState, useAIState } from 'ai/rsc'
DialogContent, import { Session } from '@/lib/types'
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { useState } from 'react'
import { Button } from './ui/button'
import { Input } from './ui/input'
import { toast } from 'react-hot-toast'
import { usePathname, useRouter } from 'next/navigation' import { usePathname, useRouter } from 'next/navigation'
import { Message } from '@/lib/chat/actions'
import { toast } from 'sonner'
const IS_PREVIEW = process.env.VERCEL_ENV === 'preview'
export interface ChatProps extends React.ComponentProps<'div'> { export interface ChatProps extends React.ComponentProps<'div'> {
initialMessages?: Message[] initialMessages?: Message[]
id?: string id?: string
session?: Session
missingKeys: string[]
} }
export function Chat({ id, initialMessages, className }: ChatProps) { export function Chat({ id, className, session, missingKeys }: ChatProps) {
const router = useRouter() const router = useRouter()
const path = usePathname() const path = usePathname()
const [previewToken, setPreviewToken] = useLocalStorage<string | null>( const [input, setInput] = useState('')
'ai-token', const [messages] = useUIState()
null const [aiState] = useAIState()
) const isLoading = true
const [previewTokenDialog, setPreviewTokenDialog] = useState(IS_PREVIEW)
const [previewTokenInput, setPreviewTokenInput] = useState(previewToken ?? '') const [_, setNewChatId] = useLocalStorage('newChatId', id)
const { messages, append, reload, stop, isLoading, input, setInput } =
useChat({ useEffect(() => {
initialMessages, if (session?.user) {
id, if (!path.includes('chat') && messages.length === 1) {
body: { window.history.replaceState({}, '', `/chat/${id}`)
id,
previewToken
},
onResponse(response) {
if (response.status === 401) {
toast.error(response.statusText)
}
},
onFinish() {
if (!path.includes('chat')) {
window.history.pushState({}, '', `/chat/${id}`)
} }
} }
}, [id, path, session?.user, messages])
useEffect(() => {
const messagesLength = aiState.messages?.length
if (messagesLength === 2) {
router.refresh()
}
}, [aiState.messages, router])
useEffect(() => {
setNewChatId(id)
}) })
useEffect(() => {
missingKeys.map(key => {
toast.error(`Missing ${key} environment variable!`)
})
}, [missingKeys])
return ( return (
<> <>
<div className={cn('pb-[200px] pt-4 md:pt-10', className)}> <div className={cn('pb-[200px] pt-4 md:pt-10', className)}>
@ -68,52 +67,7 @@ export function Chat({ id, initialMessages, className }: ChatProps) {
<EmptyScreen setInput={setInput} /> <EmptyScreen setInput={setInput} />
)} )}
</div> </div>
<ChatPanel <ChatPanel id={id} input={input} setInput={setInput} />
id={id}
isLoading={isLoading}
stop={stop}
append={append}
reload={reload}
messages={messages}
input={input}
setInput={setInput}
/>
<Dialog open={previewTokenDialog} onOpenChange={setPreviewTokenDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Enter your OpenAI Key</DialogTitle>
<DialogDescription>
If you have not obtained your OpenAI API key, you can do so by{' '}
<a
href="https://platform.openai.com/signup/"
className="underline"
>
signing up
</a>{' '}
on the OpenAI website. This is only necessary for preview
environments so that the open source community can test the app.
The token will be saved to your browser&apos;s local storage under
the name <code className="font-mono">ai-token</code>.
</DialogDescription>
</DialogHeader>
<Input
value={previewTokenInput}
placeholder="OpenAI API key"
onChange={e => setPreviewTokenInput(e.target.value)}
/>
<DialogFooter className="items-center">
<Button
onClick={() => {
setPreviewToken(previewTokenInput)
setPreviewTokenDialog(false)
}}
>
Save Token
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
) )
} }

View file

@ -2,7 +2,7 @@
import * as React from 'react' import * as React from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { toast } from 'react-hot-toast' import { toast } from 'sonner'
import { ServerActionResult } from '@/lib/types' import { ServerActionResult } from '@/lib/types'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -54,16 +54,14 @@ export function ClearHistory({
disabled={isPending} disabled={isPending}
onClick={event => { onClick={event => {
event.preventDefault() event.preventDefault()
startTransition(() => { startTransition(async () => {
clearChats().then(result => { const result = await clearChats()
if (result && 'error' in result) { if (result && 'error' in result) {
toast.error(result.error) toast.error(result.error)
return return
} }
setOpen(false) setOpen(false)
router.push('/')
})
}) })
}} }}
> >

View file

@ -22,34 +22,31 @@ const exampleMessages = [
export function EmptyScreen({ setInput }: Pick<UseChatHelpers, 'setInput'>) { export function EmptyScreen({ setInput }: Pick<UseChatHelpers, 'setInput'>) {
return ( return (
<div className="mx-auto max-w-2xl px-4"> <div className="mx-auto max-w-2xl px-4">
<div className="rounded-lg border bg-background p-8"> <div className="flex flex-col gap-2 rounded-lg border bg-background p-8">
<h1 className="mb-2 text-lg font-semibold"> <h1 className="text-lg font-semibold">
Welcome to Next.js AI Chatbot! Welcome to Next.js AI Chatbot!
</h1> </h1>
<p className="mb-2 leading-normal text-muted-foreground"> <p className="leading-normal text-muted-foreground">
This is an open source AI chatbot app template built with{' '} This is an open source AI chatbot app template built with{' '}
<ExternalLink href="https://nextjs.org">Next.js</ExternalLink> and{' '} <ExternalLink href="https://nextjs.org">Next.js</ExternalLink>, the{' '}
<ExternalLink href="https://sdk.vercel.ai">
Vercel AI SDK
</ExternalLink>
, and{' '}
<ExternalLink href="https://vercel.com/storage/kv"> <ExternalLink href="https://vercel.com/storage/kv">
Vercel KV Vercel KV
</ExternalLink> </ExternalLink>
. .
</p> </p>
<p className="leading-normal text-muted-foreground"> <p className="leading-normal text-muted-foreground">
You can start a conversation here or try the following examples: It uses{' '}
<ExternalLink href="https://vercel.com/blog/ai-sdk-3-generative-ui">
React Server Components
</ExternalLink>{' '}
to combine text with UI generated as output of the LLM. The UI state
is synced through the SDK so the model is aware of your interactions
as they happen.
</p> </p>
<div className="mt-4 flex flex-col items-start space-y-2">
{exampleMessages.map((message, index) => (
<Button
key={index}
variant="link"
className="h-auto p-0 text-base"
onClick={() => setInput(message.message)}
>
<IconArrowRight className="mr-2 text-muted-foreground" />
{message.heading}
</Button>
))}
</div>
</div> </div>
</div> </div>
) )

View file

@ -14,9 +14,10 @@ import { UserMenu } from '@/components/user-menu'
import { SidebarMobile } from './sidebar-mobile' import { SidebarMobile } from './sidebar-mobile'
import { SidebarToggle } from './sidebar-toggle' import { SidebarToggle } from './sidebar-toggle'
import { ChatHistory } from './chat-history' import { ChatHistory } from './chat-history'
import { Session } from '@/lib/types'
async function UserOrLogin() { async function UserOrLogin() {
const session = await auth() const session = (await auth()) as Session
return ( return (
<> <>
{session?.user ? ( {session?.user ? (
@ -27,7 +28,7 @@ async function UserOrLogin() {
<SidebarToggle /> <SidebarToggle />
</> </>
) : ( ) : (
<Link href="/" target="_blank" rel="nofollow"> <Link href="/" rel="nofollow">
<IconNextChat className="size-6 mr-2 dark:hidden" inverted /> <IconNextChat className="size-6 mr-2 dark:hidden" inverted />
<IconNextChat className="hidden size-6 mr-2 dark:block" /> <IconNextChat className="hidden size-6 mr-2 dark:block" />
</Link> </Link>
@ -38,7 +39,7 @@ async function UserOrLogin() {
<UserMenu user={session.user} /> <UserMenu user={session.user} />
) : ( ) : (
<Button variant="link" asChild className="-ml-2"> <Button variant="link" asChild className="-ml-2">
<Link href="/sign-in?callbackUrl=/">Login</Link> <Link href="/login">Login</Link>
</Button> </Button>
)} )}
</div> </div>
@ -65,7 +66,7 @@ export function Header() {
<span className="hidden ml-2 md:flex">GitHub</span> <span className="hidden ml-2 md:flex">GitHub</span>
</a> </a>
<a <a
href="https://github.com/vercel/nextjs-ai-chatbot/" href="https://vercel.com/templates/Next.js/nextjs-ai-chatbot"
target="_blank" target="_blank"
className={cn(buttonVariants())} className={cn(buttonVariants())}
> >

93
components/login-form.tsx Normal file
View file

@ -0,0 +1,93 @@
'use client'
import { useFormState, useFormStatus } from 'react-dom'
import { authenticate } from '@/app/login/actions'
import Link from 'next/link'
import { useEffect } from 'react'
import { toast } from 'sonner'
import { IconSpinner } from './ui/icons'
export default function LoginForm() {
const [result, dispatch] = useFormState(authenticate, undefined)
useEffect(() => {
if (result) {
if (result.type === 'error') {
toast.error(result.message)
} else {
toast.success(result.message)
}
}
}, [result])
return (
<form
action={dispatch}
className="flex flex-col items-center gap-4 space-y-3"
>
<div className="w-full flex-1 rounded-lg border bg-white px-6 pb-4 pt-8 shadow-md dark:bg-zinc-950 md:w-96">
<h1 className="mb-3 text-2xl font-bold">Please log in to continue.</h1>
<div className="w-full">
<div>
<label
className="mb-3 mt-5 block text-xs font-medium text-zinc-400"
htmlFor="email"
>
Email
</label>
<div className="relative">
<input
className="peer block w-full rounded-md border bg-zinc-50 px-2 py-[9px] text-sm outline-none placeholder:text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950"
id="email"
type="email"
name="email"
placeholder="Enter your email address"
required
/>
</div>
</div>
<div className="mt-4">
<label
className="mb-3 mt-5 block text-xs font-medium text-zinc-400"
htmlFor="password"
>
Password
</label>
<div className="relative">
<input
className="peer block w-full rounded-md border bg-zinc-50 px-2 py-[9px] text-sm outline-none placeholder:text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950"
id="password"
type="password"
name="password"
placeholder="Enter password"
required
minLength={6}
/>
</div>
</div>
</div>
<LoginButton />
</div>
<Link
href="/signup"
className="flex flex-row gap-1 text-sm text-zinc-400"
>
No account yet? <div className="font-semibold underline">Sign up</div>
</Link>
</form>
)
}
function LoginButton() {
const { pending } = useFormStatus()
return (
<button
className="flex flex-row justify-center items-center my-4 h-10 w-full rounded-md bg-zinc-900 p-2 text-sm font-semibold text-zinc-100 hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
aria-disabled={pending}
>
{pending ? <IconSpinner /> : 'Log in'}
</button>
)
}

View file

@ -1,32 +1,36 @@
'use client'
import * as React from 'react' import * as React from 'react'
import Textarea from 'react-textarea-autosize' import Textarea from 'react-textarea-autosize'
import { UseChatHelpers } from 'ai/react'
import { useEnterSubmit } from '@/lib/hooks/use-enter-submit' import { useActions, useUIState } from 'ai/rsc'
import { cn } from '@/lib/utils'
import { Button, buttonVariants } from '@/components/ui/button' import { UserMessage } from './stocks/message'
import { type AI } from '@/lib/chat/actions'
import { Button } from '@/components/ui/button'
import { IconArrowElbow, IconPlus } from '@/components/ui/icons'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipTrigger TooltipTrigger
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { IconArrowElbow, IconPlus } from '@/components/ui/icons' import { useEnterSubmit } from '@/lib/hooks/use-enter-submit'
import { nanoid } from 'nanoid'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
export interface PromptProps
extends Pick<UseChatHelpers, 'input' | 'setInput'> {
onSubmit: (value: string) => void
isLoading: boolean
}
export function PromptForm({ export function PromptForm({
onSubmit,
input, input,
setInput, setInput
isLoading }: {
}: PromptProps) { input: string
setInput: (value: string) => void
}) {
const router = useRouter()
const { formRef, onKeyDown } = useEnterSubmit() const { formRef, onKeyDown } = useEnterSubmit()
const inputRef = React.useRef<HTMLTextAreaElement>(null) const inputRef = React.useRef<HTMLTextAreaElement>(null)
const router = useRouter() const { submitUserMessage } = useActions()
const [_, setMessages] = useUIState<typeof AI>()
React.useEffect(() => { React.useEffect(() => {
if (inputRef.current) { if (inputRef.current) {
inputRef.current.focus() inputRef.current.focus()
@ -35,33 +39,47 @@ export function PromptForm({
return ( return (
<form <form
onSubmit={async e => {
e.preventDefault()
if (!input?.trim()) {
return
}
setInput('')
await onSubmit(input)
}}
ref={formRef} ref={formRef}
onSubmit={async (e: any) => {
e.preventDefault()
// Blur focus on mobile
if (window.innerWidth < 600) {
e.target['message']?.blur()
}
const value = input.trim()
setInput('')
if (!value) return
// Optimistically add user message UI
setMessages(currentMessages => [
...currentMessages,
{
id: nanoid(),
display: <UserMessage>{value}</UserMessage>
}
])
// Submit and get response message
const responseMessage = await submitUserMessage(value)
setMessages(currentMessages => [...currentMessages, responseMessage])
}}
> >
<div className="relative flex flex-col w-full px-8 overflow-hidden max-h-60 grow bg-background sm:rounded-md sm:border sm:px-12"> <div className="relative flex max-h-60 w-full grow flex-col overflow-hidden bg-background px-8 sm:rounded-md sm:border sm:px-12">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <Button
onClick={e => { variant="outline"
e.preventDefault() size="icon"
router.refresh() className="absolute left-0 top-4 size-8 rounded-full bg-background p-0 sm:left-4"
onClick={() => {
router.push('/') router.push('/')
}} }}
className={cn(
buttonVariants({ size: 'sm', variant: 'outline' }),
'absolute left-0 top-4 size-8 rounded-full bg-background p-0 sm:left-4'
)}
> >
<IconPlus /> <IconPlus />
<span className="sr-only">New Chat</span> <span className="sr-only">New Chat</span>
</button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>New Chat</TooltipContent> <TooltipContent>New Chat</TooltipContent>
</Tooltip> </Tooltip>
@ -69,21 +87,21 @@ export function PromptForm({
ref={inputRef} ref={inputRef}
tabIndex={0} tabIndex={0}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
placeholder="Send a message."
className="min-h-[60px] w-full resize-none bg-transparent px-4 py-[1.3rem] focus-within:outline-none sm:text-sm"
autoFocus
spellCheck={false}
autoComplete="off"
autoCorrect="off"
name="message"
rows={1} rows={1}
value={input} value={input}
onChange={e => setInput(e.target.value)} onChange={e => setInput(e.target.value)}
placeholder="Send a message."
spellCheck={false}
className="min-h-[60px] w-full resize-none bg-transparent px-4 py-[1.3rem] focus-within:outline-none sm:text-sm"
/> />
<div className="absolute right-0 top-4 sm:right-4"> <div className="absolute right-0 top-4 sm:right-4">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button type="submit" size="icon" disabled={input === ''}>
type="submit"
size="icon"
disabled={isLoading || input === ''}
>
<IconArrowElbow /> <IconArrowElbow />
<span className="sr-only">Send message</span> <span className="sr-only">Send message</span>
</Button> </Button>

View file

@ -2,7 +2,7 @@
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import * as React from 'react' import * as React from 'react'
import { toast } from 'react-hot-toast' import { toast } from 'sonner'
import { ServerActionResult, type Chat } from '@/lib/types' import { ServerActionResult, type Chat } from '@/lib/types'
import { import {
@ -42,12 +42,12 @@ export function SidebarActions({
return ( return (
<> <>
<div className="space-x-1"> <div className="">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
className="size-6 p-0 hover:bg-background" className="size-7 p-0 hover:bg-background"
onClick={() => setShareDialogOpen(true)} onClick={() => setShareDialogOpen(true)}
> >
<IconShare /> <IconShare />
@ -60,7 +60,7 @@ export function SidebarActions({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
className="size-6 p-0 hover:bg-background" className="size-7 p-0 hover:bg-background"
disabled={isRemovePending} disabled={isRemovePending}
onClick={() => setDeleteDialogOpen(true)} onClick={() => setDeleteDialogOpen(true)}
> >

View file

@ -20,7 +20,10 @@ export function SidebarMobile({ children }: SidebarMobileProps) {
<span className="sr-only">Toggle Sidebar</span> <span className="sr-only">Toggle Sidebar</span>
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent className="inset-y-0 flex h-auto w-[300px] flex-col p-0"> <SheetContent
side="left"
className="inset-y-0 flex h-auto w-[300px] flex-col p-0"
>
<Sidebar className="flex">{children}</Sidebar> <Sidebar className="flex">{children}</Sidebar>
</SheetContent> </SheetContent>
</Sheet> </Sheet>

View file

@ -0,0 +1,94 @@
'use client'
import { useFormState, useFormStatus } from 'react-dom'
import { signup } from '@/app/signup/actions'
import Link from 'next/link'
import { useEffect } from 'react'
import { toast } from 'sonner'
import { IconSpinner } from './ui/icons'
import { useRouter } from 'next/navigation'
export default function SignupForm() {
const router = useRouter()
const [result, dispatch] = useFormState(signup, undefined)
useEffect(() => {
if (result) {
if (result.type === 'error') {
toast.error(result.message)
} else {
router.refresh()
toast.success(result.message)
}
}
}, [result, router])
return (
<form
action={dispatch}
className="flex flex-col items-center gap-4 space-y-3"
>
<div className="w-full flex-1 rounded-lg border bg-white px-6 pb-4 pt-8 shadow-md dark:bg-zinc-950 md:w-96">
<h1 className="mb-3 text-2xl font-bold">Sign up for an account!</h1>
<div className="w-full">
<div>
<label
className="mb-3 mt-5 block text-xs font-medium text-zinc-400"
htmlFor="email"
>
Email
</label>
<div className="relative">
<input
className="peer block w-full rounded-md border bg-zinc-50 px-2 py-[9px] text-sm outline-none placeholder:text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950"
id="email"
type="email"
name="email"
placeholder="Enter your email address"
required
/>
</div>
</div>
<div className="mt-4">
<label
className="mb-3 mt-5 block text-xs font-medium text-zinc-400"
htmlFor="password"
>
Password
</label>
<div className="relative">
<input
className="peer block w-full rounded-md border bg-zinc-50 px-2 py-[9px] text-sm outline-none placeholder:text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950"
id="password"
type="password"
name="password"
placeholder="Enter password"
required
minLength={6}
/>
</div>
</div>
</div>
<LoginButton />
</div>
<Link href="/login" className="flex flex-row gap-1 text-sm text-zinc-400">
Already have an account?
<div className="font-semibold underline">Log in</div>
</Link>
</form>
)
}
function LoginButton() {
const { pending } = useFormStatus()
return (
<button
className="flex flex-row justify-center items-center my-4 h-10 w-full rounded-md bg-zinc-900 p-2 text-sm font-semibold text-zinc-100 hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
aria-disabled={pending}
>
{pending ? <IconSpinner /> : 'Create account'}
</button>
)
}

View file

@ -0,0 +1,30 @@
import { format, parseISO } from 'date-fns'
interface Event {
date: string
headline: string
description: string
}
export function Events({ props: events }: { props: Event[] }) {
return (
<div className="-mt-2 flex flex-col gap-2">
{events.map(event => (
<div
key={event.date}
className="max-w-96 flex shrink-0 flex-col rounded-lg bg-zinc-800 p-4 gap-1"
>
<div className="text-sm text-zinc-400">
{format(parseISO(event.date), 'dd LLL, yyyy')}
</div>
<div className="text-base font-bold text-zinc-200">
{event.headline}
</div>
<div className="text-zinc-500">
{event.description.slice(0, 70)}...
</div>
</div>
))}
</div>
)
}

View file

@ -0,0 +1,31 @@
const placeholderEvents = [
{
date: '2022-10-01',
headline: 'NVIDIA releases new AI-powered graphics card',
description:
'NVIDIA unveils the latest graphics card infused with AI capabilities, revolutionizing gaming and rendering experiences.'
}
]
export const EventsSkeleton = ({ events = placeholderEvents }) => {
return (
<div className="-mt-2 flex flex-col gap-2 py-4">
{placeholderEvents.map(event => (
<div
key={event.date}
className="max-w-96 flex gap-1 shrink-0 flex-col rounded-lg bg-zinc-800 p-4"
>
<div className="w-fit rounded-md bg-zinc-700 text-sm text-transparent">
{event.date}
</div>
<div className="w-fit rounded-md bg-zinc-700 text-transparent">
{event.headline}
</div>
<div className="w-auto rounded-md bg-zinc-700 text-transparent">
{event.description.slice(0, 70)}...
</div>
</div>
))}
</div>
)
}

View file

@ -0,0 +1,38 @@
'use client'
import dynamic from 'next/dynamic'
import { StockSkeleton } from './stock-skeleton'
import { StocksSkeleton } from './stocks-skeleton'
import { EventsSkeleton } from './events-skeleton'
export { spinner } from './spinner'
export { BotCard, BotMessage, SystemMessage } from './message'
const Stock = dynamic(() => import('./stock').then(mod => mod.Stock), {
ssr: false,
loading: () => <StockSkeleton />
})
const Purchase = dynamic(
() => import('./stock-purchase').then(mod => mod.Purchase),
{
ssr: false,
loading: () => (
<div className="rounded-lg bg-zinc-900 px-4 py-5 text-center text-xs">
Loading stock info...
</div>
)
}
)
const Stocks = dynamic(() => import('./stocks').then(mod => mod.Stocks), {
ssr: false,
loading: () => <StocksSkeleton />
})
const Events = dynamic(() => import('./event').then(mod => mod.Events), {
ssr: false,
loading: () => <EventsSkeleton />
})
export { Stock, Purchase, Stocks, Events }

View file

@ -0,0 +1,134 @@
'use client'
import { IconOpenAI, IconUser } from '@/components/ui/icons'
import { cn } from '@/lib/utils'
import { spinner } from './spinner'
import { CodeBlock } from '../ui/codeblock'
import { MemoizedReactMarkdown } from '../markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import { StreamableValue } from 'ai/rsc'
import { useStreamableText } from '@/lib/hooks/use-streamable-text'
// Different types of message bubbles.
export function UserMessage({ children }: { children: React.ReactNode }) {
return (
<div className="group relative flex items-start md:-ml-12">
<div className="flex size-[25px] shrink-0 select-none items-center justify-center rounded-md border bg-background shadow-sm">
<IconUser />
</div>
<div className="ml-4 flex-1 space-y-2 overflow-hidden px-1">
{children}
</div>
</div>
)
}
export function BotMessage({
content,
className
}: {
content: string | StreamableValue<string>
className?: string
}) {
const text = useStreamableText(content)
return (
<div className={cn('group relative flex items-start md:-ml-12', className)}>
<div className="flex size-[24px] shrink-0 select-none items-center justify-center rounded-md border bg-primary text-primary-foreground shadow-sm">
<IconOpenAI />
</div>
<div className="ml-4 flex-1 space-y-2 overflow-hidden px-1">
<MemoizedReactMarkdown
className="prose break-words dark:prose-invert prose-p:leading-relaxed prose-pre:p-0"
remarkPlugins={[remarkGfm, remarkMath]}
components={{
p({ children }) {
return <p className="mb-2 last:mb-0">{children}</p>
},
code({ node, inline, className, children, ...props }) {
if (children.length) {
if (children[0] == '▍') {
return (
<span className="mt-1 animate-pulse cursor-default"></span>
)
}
children[0] = (children[0] as string).replace('`▍`', '▍')
}
const match = /language-(\w+)/.exec(className || '')
if (inline) {
return (
<code className={className} {...props}>
{children}
</code>
)
}
return (
<CodeBlock
key={Math.random()}
language={(match && match[1]) || ''}
value={String(children).replace(/\n$/, '')}
{...props}
/>
)
}
}}
>
{text}
</MemoizedReactMarkdown>
</div>
</div>
)
}
export function BotCard({
children,
showAvatar = true
}: {
children: React.ReactNode
showAvatar?: boolean
}) {
return (
<div className="group relative flex items-start md:-ml-12">
<div
className={cn(
'flex size-[24px] shrink-0 select-none items-center justify-center rounded-md border bg-primary text-primary-foreground shadow-sm',
!showAvatar && 'invisible'
)}
>
<IconOpenAI />
</div>
<div className="ml-4 flex-1 px-1">{children}</div>
</div>
)
}
export function SystemMessage({ children }: { children: React.ReactNode }) {
return (
<div
className={
'mt-2 flex items-center justify-center gap-2 text-xs text-gray-500'
}
>
<div className={'max-w-[600px] flex-initial p-2'}>{children}</div>
</div>
)
}
export function SpinnerMessage() {
return (
<div className="group relative flex items-start md:-ml-12">
<div className="flex size-[24px] shrink-0 select-none items-center justify-center rounded-md border bg-primary text-primary-foreground shadow-sm">
<IconOpenAI />
</div>
<div className="ml-4 h-[24px] flex flex-row items-center flex-1 space-y-2 overflow-hidden px-1">
{spinner}
</div>
</div>
)
}

View file

@ -0,0 +1,16 @@
'use client'
export const spinner = (
<svg
fill="none"
stroke="currentColor"
strokeWidth="1.5"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
xmlns="http://www.w3.org/2000/svg"
className="size-5 animate-spin stroke-zinc-400"
>
<path d="M12 3v3m6.366-.366-2.12 2.12M21 12h-3m.366 6.366-2.12-2.12M12 21v-3m-6.366.366 2.12-2.12M3 12h3m-.366-6.366 2.12 2.12"></path>
</svg>
)

View file

@ -0,0 +1,146 @@
'use client'
import { useId, useState } from 'react'
import { useActions, useAIState, useUIState } from 'ai/rsc'
import { formatNumber } from '@/lib/utils'
import type { AI } from '@/lib/chat/actions'
interface Purchase {
defaultAmount?: number
name: string
price: number
status: 'requires_action' | 'completed' | 'expired'
}
export function Purchase({
props: { defaultAmount, name, price, status = 'expired' }
}: {
props: Purchase
}) {
const [value, setValue] = useState(defaultAmount || 100)
const [purchasingUI, setPurchasingUI] = useState<null | React.ReactNode>(null)
const [aiState, setAIState] = useAIState<typeof AI>()
const [, setMessages] = useUIState<typeof AI>()
const { confirmPurchase } = useActions()
// Unique identifier for this UI component.
const id = useId()
// Whenever the slider changes, we need to update the local value state and the history
// so LLM also knows what's going on.
function onSliderChange(e: React.ChangeEvent<HTMLInputElement>) {
const newValue = Number(e.target.value)
setValue(newValue)
// Insert a hidden history info to the list.
const message = {
role: 'system' as const,
content: `[User has changed to purchase ${newValue} shares of ${name}. Total cost: $${(
newValue * price
).toFixed(2)}]`,
// Identifier of this UI component, so we don't insert it many times.
id
}
// If last history state is already this info, update it. This is to avoid
// adding every slider change to the history.
if (aiState.messages[aiState.messages.length - 1]?.id === id) {
setAIState({
...aiState,
messages: [...aiState.messages.slice(0, -1), message]
})
return
}
// If it doesn't exist, append it to history.
setAIState({ ...aiState, messages: [...aiState.messages, message] })
}
return (
<div className="rounded-xl border bg-zinc-950 p-4 text-green-400 mt-2">
<div className="float-right inline-block rounded-full bg-white/10 px-2 py-1 text-xs">
+1.23%
</div>
<div className="text-lg text-zinc-300">{name}</div>
<div className="text-3xl font-bold">${price}</div>
{purchasingUI ? (
<div className="mt-4 text-zinc-200">{purchasingUI}</div>
) : status === 'requires_action' ? (
<>
<div className="relative mt-6 pb-6">
<p>Shares to purchase</p>
<input
id="labels-range-input"
type="range"
value={value}
onChange={onSliderChange}
min="10"
max="1000"
className="h-1 w-full cursor-pointer appearance-none rounded-lg bg-zinc-600 accent-green-500 dark:bg-zinc-700"
/>
<span className="absolute bottom-1 start-0 text-xs text-zinc-400">
10
</span>
<span className="absolute bottom-1 start-1/3 -translate-x-1/2 text-xs text-zinc-400 rtl:translate-x-1/2">
100
</span>
<span className="absolute bottom-1 start-2/3 -translate-x-1/2 text-xs text-zinc-400 rtl:translate-x-1/2">
500
</span>
<span className="absolute bottom-1 end-0 text-xs text-zinc-400">
1000
</span>
</div>
<div className="mt-6">
<p>Total cost</p>
<div className="flex flex-wrap items-center text-xl font-bold sm:items-end sm:gap-2 sm:text-3xl">
<div className="flex basis-1/3 flex-col tabular-nums sm:basis-auto sm:flex-row sm:items-center sm:gap-2">
{value}
<span className="mb-1 text-sm font-normal text-zinc-600 dark:text-zinc-400 sm:mb-0">
shares
</span>
</div>
<div className="basis-1/3 text-center sm:basis-auto">×</div>
<span className="flex basis-1/3 flex-col tabular-nums sm:basis-auto sm:flex-row sm:items-center sm:gap-2">
${price}
<span className="mb-1 ml-1 text-sm font-normal text-zinc-600 dark:text-zinc-400 sm:mb-0">
per share
</span>
</span>
<div className="mt-2 basis-full border-t border-t-zinc-700 pt-2 text-center sm:mt-0 sm:basis-auto sm:border-0 sm:pt-0 sm:text-left">
= <span>{formatNumber(value * price)}</span>
</div>
</div>
</div>
<button
className="mt-6 w-full rounded-lg bg-green-500 px-4 py-2 text-zinc-900 dark:bg-green-500"
onClick={async () => {
const response = await confirmPurchase(name, price, value)
setPurchasingUI(response.purchasingUI)
// Insert a new system message to the UI.
setMessages((currentMessages: any) => [
...currentMessages,
response.newMessage
])
}}
>
Purchase
</button>
</>
) : status === 'completed' ? (
<p className="mb-2 text-white">
You have successfully purchased {value} ${name}. Total cost:{' '}
{formatNumber(value * price)}
</p>
) : status === 'expired' ? (
<p className="mb-2 text-white">Your checkout session has expired!</p>
) : null}
</div>
)
}

View file

@ -0,0 +1,22 @@
export const StockSkeleton = () => {
return (
<div className="rounded-xl border border-zinc-800 bg-zinc-950 p-4 text-green-400">
<div className="float-right inline-block w-fit rounded-full bg-zinc-700 px-2 py-1 text-xs text-transparent">
xxxxxxx
</div>
<div className="mb-1 w-fit rounded-md bg-zinc-700 text-lg text-transparent">
xxxx
</div>
<div className="w-fit rounded-md bg-zinc-700 text-3xl font-bold text-transparent">
xxxx
</div>
<div className="text mt-1 w-fit rounded-md bg-zinc-700 text-xs text-transparent">
xxxxxx xxx xx xxxx xx xxx
</div>
<div className="relative -mx-4 cursor-col-resize">
<div style={{ height: 146 }}></div>
</div>
</div>
)
}

210
components/stocks/stock.tsx Normal file
View file

@ -0,0 +1,210 @@
'use client'
import { useState, useRef, useEffect, useId } from 'react'
import { scaleLinear } from 'd3-scale'
import { subMonths, format } from 'date-fns'
import { useResizeObserver } from 'usehooks-ts'
import { useAIState } from 'ai/rsc'
interface Stock {
symbol: string
price: number
delta: number
}
export function Stock({ props: { symbol, price, delta } }: { props: Stock }) {
const [aiState, setAIState] = useAIState()
const id = useId()
const [priceAtTime, setPriceAtTime] = useState({
time: '00:00',
value: price.toFixed(2),
x: 0
})
const [startHighlight, setStartHighlight] = useState(0)
const [endHighlight, setEndHighlight] = useState(0)
const chartRef = useRef<HTMLDivElement>(null)
const { width = 0 } = useResizeObserver({
ref: chartRef,
box: 'border-box'
})
const xToDate = scaleLinear(
[0, width],
[subMonths(new Date(), 6), new Date()]
)
const xToValue = scaleLinear(
[0, width],
[price - price / 2, price + price / 2]
)
useEffect(() => {
if (startHighlight && endHighlight) {
const message = {
id,
role: 'system' as const,
content: `[User has highlighted dates between between ${format(
xToDate(startHighlight),
'd LLL'
)} and ${format(xToDate(endHighlight), 'd LLL, yyyy')}`
}
if (aiState.messages[aiState.messages.length - 1]?.id === id) {
setAIState({
...aiState,
messages: [...aiState.messages.slice(0, -1), message]
})
} else {
setAIState({
...aiState,
messages: [...aiState.messages, message]
})
}
}
}, [startHighlight, endHighlight])
return (
<div className="rounded-xl border bg-zinc-950 p-4 text-green-400">
<div className="float-right inline-block rounded-full bg-white/10 px-2 py-1 text-xs">
{`${delta > 0 ? '+' : ''}${((delta / price) * 100).toFixed(2)}% ${
delta > 0 ? '↑' : '↓'
}`}
</div>
<div className="text-lg text-zinc-300">{symbol}</div>
<div className="text-3xl font-bold">${price}</div>
<div className="text mt-1 text-xs text-zinc-500">
Closed: Feb 27, 4:59 PM EST
</div>
<div
className="relative -mx-4 cursor-col-resize"
onPointerDown={event => {
if (chartRef.current) {
const { clientX } = event
const { left } = chartRef.current.getBoundingClientRect()
setStartHighlight(clientX - left)
setEndHighlight(0)
setPriceAtTime({
time: format(xToDate(clientX), 'dd LLL yy'),
value: xToValue(clientX).toFixed(2),
x: clientX - left
})
}
}}
onPointerUp={event => {
if (chartRef.current) {
const { clientX } = event
const { left } = chartRef.current.getBoundingClientRect()
setEndHighlight(clientX - left)
}
}}
onPointerMove={event => {
if (chartRef.current) {
const { clientX } = event
const { left } = chartRef.current.getBoundingClientRect()
setPriceAtTime({
time: format(xToDate(clientX), 'dd LLL yy'),
value: xToValue(clientX).toFixed(2),
x: clientX - left
})
}
}}
onPointerLeave={() => {
setPriceAtTime({
time: '00:00',
value: price.toFixed(2),
x: 0
})
}}
ref={chartRef}
>
{priceAtTime.x > 0 ? (
<div
className="pointer-events-none absolute z-10 flex w-fit select-none gap-2 rounded-md bg-zinc-800 p-2"
style={{
left: priceAtTime.x - 124 / 2,
top: 30
}}
>
<div className="text-xs tabular-nums">${priceAtTime.value}</div>
<div className="text-xs tabular-nums text-zinc-400">
{priceAtTime.time}
</div>
</div>
) : null}
{startHighlight ? (
<div
className="pointer-events-none absolute h-32 w-5 select-none rounded-md border border-zinc-500 bg-zinc-500/20"
style={{
left: startHighlight,
width: endHighlight
? endHighlight - startHighlight
: priceAtTime.x - startHighlight,
bottom: 0
}}
></div>
) : null}
<svg
viewBox="0 0 250.0 168.0"
height="150"
width="100%"
preserveAspectRatio="none"
>
<defs>
<linearGradient
id="fill-id-tsuid_31"
x1="0%"
x2="0%"
y1="0%"
y2="100%"
>
<stop offset="0%" stopColor="#34a853" stopOpacity="0.38"></stop>
<stop offset="13%" stopColor="#e6f4ea" stopOpacity="0"></stop>
</linearGradient>
<clipPath id="range-id-tsuid_31">
<rect height="100%" width="0" x="0" y="0"></rect>
</clipPath>
<defs>
<linearGradient
id="chart-grad-_f1bJZYLUHqWpxc8Prs2meA_33"
x1="0%"
x2="0%"
y1="0%"
y2="100%"
>
<stop offset="0%" stopColor="#34a853" stopOpacity="0.38"></stop>
<stop offset="13%" stopColor="#e6f4ea" stopOpacity="0"></stop>
</linearGradient>
</defs>
<clipPath id="mask-_f1bJZYLUHqWpxc8Prs2meA_32">
<rect height="218" width="250" x="0" y="-5"></rect>
</clipPath>
</defs>
<path
d="M 0 42.86 L 0.89 46.26 L 1.78 44.3 L 2.68 44.24 L 3.57 42 L 4.46 43.42 L 5.35 43.62 L 6.25 47 L 7.14 47.65 L 8.03 47.69 L 8.92 45.55 L 9.82 43.19 L 10.71 43.9 L 11.6 42.83 L 12.49 42.81 L 13.39 46.75 L 14.28 43.06 L 15.17 40.8 L 16.06 39.72 L 16.96 39.77 L 17.85 45.77 L 18.74 44.93 L 19.63 44.35 L 20.53 40.29 L 21.42 42.77 L 22.31 42.12 L 23.2 43.4 L 24.1 47.95 L 24.99 50.15 L 25.88 48.59 L 26.77 42.18 L 27.67 44.1 L 28.56 39.91 L 29.45 44.92 L 30.34 47.62 L 31.24 48.06 L 32.13 47.67 L 33.02 56.47 L 33.91 57.74 L 34.8 65.48 L 35.7 64.47 L 36.59 47.25 L 37.48 58.26 L 38.37 52.04 L 39.27 55.8 L 40.16 92.92 L 41.05 105.2 L 41.94 102 L 42.84 106.14 L 43.73 78.71 L 44.62 104.6 L 45.51 96.58 L 46.41 67.56 L 47.3 69.53 L 48.19 69.99 L 49.08 66.75 L 49.98 69.72 L 50.87 70.13 L 51.76 71.3 L 52.65 70.03 L 53.55 67.92 L 54.44 66.41 L 55.33 97.12 L 56.22 95.93 L 57.12 95.03 L 58.01 95.09 L 58.9 65.56 L 59.79 65.12 L 60.69 82.42 L 61.58 74.7 L 62.47 71.13 L 63.36 82.43 L 64.26 96.02 L 65.15 100.36 L 66.04 98.6 L 66.93 103.37 L 67.82 102.12 L 68.72 97.08 L 69.61 89.74 L 70.5 90.7 L 71.39 93.46 L 72.29 94.24 L 73.18 97.8 L 74.07 97.88 L 74.96 96.63 L 75.86 96.27 L 76.75 97.15 L 77.64 100.12 L 78.53 100.51 L 79.43 106.59 L 80.32 104.54 L 81.21 100.31 L 82.1 118.76 L 83 106.24 L 83.89 114.8 L 84.78 174.89 L 85.67 122.28 L 86.57 149.25 L 87.46 151.47 L 88.35 153.38 L 89.24 153.5 L 90.14 149.24 L 91.03 122.44 L 91.92 122.08 L 92.81 147.16 L 93.71 147.46 L 94.6 119.13 L 95.49 117.97 L 96.38 122.22 L 97.28 116.38 L 98.17 119.53 L 99.06 119.65 L 99.95 120.15 L 100.84 120.22 L 101.74 121.28 L 102.63 121.4 L 103.52 122.97 L 104.41 122.15 L 105.31 120.6 L 106.2 116.55 L 107.09 122.23 L 107.98 120.96 L 108.88 119.54 L 109.77 120.19 L 110.66 120.99 L 111.55 119.92 L 112.45 115.69 L 113.34 116.33 L 114.23 116.07 L 115.12 115.34 L 116.02 111.34 L 116.91 107.23 L 117.8 113.21 L 118.69 98.77 L 119.59 97.04 L 120.48 96.56 L 121.37 96.36 L 122.26 99.7 L 123.16 103.33 L 124.05 100.38 L 124.94 99.68 L 125.83 99.02 L 126.73 102.56 L 127.62 103.25 L 128.51 103.38 L 129.4 104.89 L 130.3 118.07 L 131.19 100.82 L 132.08 103.06 L 132.97 103.47 L 133.86 99.8 L 134.76 111.28 L 135.65 107.73 L 136.54 107.46 L 137.43 108.08 L 138.33 109.82 L 139.22 110.94 L 140.11 111.3 L 141 108.14 L 141.9 109.35 L 142.79 108.38 L 143.68 99.08 L 144.57 99.02 L 145.47 98.61 L 146.36 99.07 L 147.25 99.26 L 148.14 95.1 L 149.04 92.08 L 149.93 92.76 L 150.82 92.87 L 151.71 83.31 L 152.61 82.93 L 153.5 84.86 L 154.39 84.12 L 155.28 94.08 L 156.18 93.31 L 157.07 94.23 L 157.96 94.58 L 158.85 99.33 L 159.75 80 L 160.64 90.28 L 161.53 84.07 L 162.42 68.37 L 163.32 76.88 L 164.21 81.78 L 165.1 80.72 L 165.99 73.89 L 166.88 77.14 L 167.78 67.58 L 168.67 59.82 L 169.56 61.91 L 170.45 61.07 L 171.35 73.74 L 172.24 77.02 L 173.13 78.61 L 174.02 71.59 L 174.92 68.24 L 175.81 72.14 L 176.7 65.37 L 177.59 76.73 L 178.49 88.02 L 179.38 88.01 L 180.27 88.27 L 181.16 86.23 L 182.06 86.14 L 182.95 89.54 L 183.84 94.16 L 184.73 97.72 L 185.63 81.52 L 186.52 92.85 L 187.41 94.14 L 188.3 93.06 L 189.2 92.64 L 190.09 92.44 L 190.98 91.75 L 191.87 90.53 L 192.77 88.27 L 193.66 85.44 L 194.55 82.26 L 195.44 85.08 L 196.34 85.65 L 197.23 53.43 L 198.12 72.01 L 199.01 38.37 L 199.9 69.43 L 200.8 74.46 L 201.69 74.22 L 202.58 82.46 L 203.47 77.01 L 204.37 87.8 L 205.26 91.56 L 206.15 76.69 L 207.04 76.46 L 207.94 78.13 L 208.83 80.06 L 209.72 92.79 L 210.61 87.74 L 211.51 88.21 L 212.4 88.47 L 213.29 87.35 L 214.18 89.69 L 215.08 77.37 L 215.97 87.95 L 216.86 75.16 L 217.75 70.47 L 218.65 85.11 L 219.54 88.1 L 220.43 88.06 L 221.32 86.34 L 222.22 76.91 L 223.11 75.33 L 224 73.6 L 224.89 25.31 L 225.79 44.14 L 226.68 43.93 L 227.57 45.13 L 228.46 44.03 L 229.36 35.73 L 230.25 33.65 L 231.14 34.81 L 232.03 17.64 L 232.92 21.13 L 233.82 19.37 L 234.71 24.66 L 235.6 23.87 L 236.49 22.56 L 237.39 28.48 L 238.28 25.33 L 239.17 28.51 L 240.06 30.83 L 240.96 35.79 L 241.85 34.6 L 242.74 31.2 L 243.63 32.97 L 244.53 33.01 L 245.42 31.38 L 246.31 30.21 L 247.2 27.75 L 248.1 25.27 L 248.99 23 L 249.88 23 L 250 23 L 2000 0 L 2000 1000 L -1000 1000"
clipPath="url(#range-id-tsuid_31)"
vectorEffect="non-scaling-stroke"
stroke="none"
strokeWidth={2}
fill='url("#fill-id-tsuid_31")'
></path>
<path
clipPath="url(#mask-_f1bJZYLUHqWpxc8Prs2meA_32)"
d="M 0 42.86 L 0.89 46.26 L 1.78 44.3 L 2.68 44.24 L 3.57 42 L 4.46 43.42 L 5.35 43.62 L 6.25 47 L 7.14 47.65 L 8.03 47.69 L 8.92 45.55 L 9.82 43.19 L 10.71 43.9 L 11.6 42.83 L 12.49 42.81 L 13.39 46.75 L 14.28 43.06 L 15.17 40.8 L 16.06 39.72 L 16.96 39.77 L 17.85 45.77 L 18.74 44.93 L 19.63 44.35 L 20.53 40.29 L 21.42 42.77 L 22.31 42.12 L 23.2 43.4 L 24.1 47.95 L 24.99 50.15 L 25.88 48.59 L 26.77 42.18 L 27.67 44.1 L 28.56 39.91 L 29.45 44.92 L 30.34 47.62 L 31.24 48.06 L 32.13 47.67 L 33.02 56.47 L 33.91 57.74 L 34.8 65.48 L 35.7 64.47 L 36.59 47.25 L 37.48 58.26 L 38.37 52.04 L 39.27 55.8 L 40.16 92.92 L 41.05 105.2 L 41.94 102 L 42.84 106.14 L 43.73 78.71 L 44.62 104.6 L 45.51 96.58 L 46.41 67.56 L 47.3 69.53 L 48.19 69.99 L 49.08 66.75 L 49.98 69.72 L 50.87 70.13 L 51.76 71.3 L 52.65 70.03 L 53.55 67.92 L 54.44 66.41 L 55.33 97.12 L 56.22 95.93 L 57.12 95.03 L 58.01 95.09 L 58.9 65.56 L 59.79 65.12 L 60.69 82.42 L 61.58 74.7 L 62.47 71.13 L 63.36 82.43 L 64.26 96.02 L 65.15 100.36 L 66.04 98.6 L 66.93 103.37 L 67.82 102.12 L 68.72 97.08 L 69.61 89.74 L 70.5 90.7 L 71.39 93.46 L 72.29 94.24 L 73.18 97.8 L 74.07 97.88 L 74.96 96.63 L 75.86 96.27 L 76.75 97.15 L 77.64 100.12 L 78.53 100.51 L 79.43 106.59 L 80.32 104.54 L 81.21 100.31 L 82.1 118.76 L 83 106.24 L 83.89 114.8 L 84.78 174.89 L 85.67 122.28 L 86.57 149.25 L 87.46 151.47 L 88.35 153.38 L 89.24 153.5 L 90.14 149.24 L 91.03 122.44 L 91.92 122.08 L 92.81 147.16 L 93.71 147.46 L 94.6 119.13 L 95.49 117.97 L 96.38 122.22 L 97.28 116.38 L 98.17 119.53 L 99.06 119.65 L 99.95 120.15 L 100.84 120.22 L 101.74 121.28 L 102.63 121.4 L 103.52 122.97 L 104.41 122.15 L 105.31 120.6 L 106.2 116.55 L 107.09 122.23 L 107.98 120.96 L 108.88 119.54 L 109.77 120.19 L 110.66 120.99 L 111.55 119.92 L 112.45 115.69 L 113.34 116.33 L 114.23 116.07 L 115.12 115.34 L 116.02 111.34 L 116.91 107.23 L 117.8 113.21 L 118.69 98.77 L 119.59 97.04 L 120.48 96.56 L 121.37 96.36 L 122.26 99.7 L 123.16 103.33 L 124.05 100.38 L 124.94 99.68 L 125.83 99.02 L 126.73 102.56 L 127.62 103.25 L 128.51 103.38 L 129.4 104.89 L 130.3 118.07 L 131.19 100.82 L 132.08 103.06 L 132.97 103.47 L 133.86 99.8 L 134.76 111.28 L 135.65 107.73 L 136.54 107.46 L 137.43 108.08 L 138.33 109.82 L 139.22 110.94 L 140.11 111.3 L 141 108.14 L 141.9 109.35 L 142.79 108.38 L 143.68 99.08 L 144.57 99.02 L 145.47 98.61 L 146.36 99.07 L 147.25 99.26 L 148.14 95.1 L 149.04 92.08 L 149.93 92.76 L 150.82 92.87 L 151.71 83.31 L 152.61 82.93 L 153.5 84.86 L 154.39 84.12 L 155.28 94.08 L 156.18 93.31 L 157.07 94.23 L 157.96 94.58 L 158.85 99.33 L 159.75 80 L 160.64 90.28 L 161.53 84.07 L 162.42 68.37 L 163.32 76.88 L 164.21 81.78 L 165.1 80.72 L 165.99 73.89 L 166.88 77.14 L 167.78 67.58 L 168.67 59.82 L 169.56 61.91 L 170.45 61.07 L 171.35 73.74 L 172.24 77.02 L 173.13 78.61 L 174.02 71.59 L 174.92 68.24 L 175.81 72.14 L 176.7 65.37 L 177.59 76.73 L 178.49 88.02 L 179.38 88.01 L 180.27 88.27 L 181.16 86.23 L 182.06 86.14 L 182.95 89.54 L 183.84 94.16 L 184.73 97.72 L 185.63 81.52 L 186.52 92.85 L 187.41 94.14 L 188.3 93.06 L 189.2 92.64 L 190.09 92.44 L 190.98 91.75 L 191.87 90.53 L 192.77 88.27 L 193.66 85.44 L 194.55 82.26 L 195.44 85.08 L 196.34 85.65 L 197.23 53.43 L 198.12 72.01 L 199.01 38.37 L 199.9 69.43 L 200.8 74.46 L 201.69 74.22 L 202.58 82.46 L 203.47 77.01 L 204.37 87.8 L 205.26 91.56 L 206.15 76.69 L 207.04 76.46 L 207.94 78.13 L 208.83 80.06 L 209.72 92.79 L 210.61 87.74 L 211.51 88.21 L 212.4 88.47 L 213.29 87.35 L 214.18 89.69 L 215.08 77.37 L 215.97 87.95 L 216.86 75.16 L 217.75 70.47 L 218.65 85.11 L 219.54 88.1 L 220.43 88.06 L 221.32 86.34 L 222.22 76.91 L 223.11 75.33 L 224 73.6 L 224.89 25.31 L 225.79 44.14 L 226.68 43.93 L 227.57 45.13 L 228.46 44.03 L 229.36 35.73 L 230.25 33.65 L 231.14 34.81 L 232.03 17.64 L 232.92 21.13 L 233.82 19.37 L 234.71 24.66 L 235.6 23.87 L 236.49 22.56 L 237.39 28.48 L 238.28 25.33 L 239.17 28.51 L 240.06 30.83 L 240.96 35.79 L 241.85 34.6 L 242.74 31.2 L 243.63 32.97 L 244.53 33.01 L 245.42 31.38 L 246.31 30.21 L 247.2 27.75 L 248.1 25.27 L 248.99 23 L 249.88 23 L 250 23 L 2000 0 L 2000 1000 L -1000 1000"
vectorEffect="non-scaling-stroke"
stroke="#34a853"
style={{ fill: 'url(#chart-grad-_f1bJZYLUHqWpxc8Prs2meA_33)' }}
></path>
</svg>
</div>
</div>
)
}

View file

@ -0,0 +1,9 @@
export const StocksSkeleton = () => {
return (
<div className="mb-4 flex flex-col gap-2 overflow-y-scroll pb-4 text-sm sm:flex-row">
<div className="flex h-[60px] w-full cursor-pointer flex-row gap-2 rounded-lg bg-zinc-900 p-2 text-left hover:bg-zinc-800 sm:w-[208px]"></div>
<div className="flex h-[60px] w-full cursor-pointer flex-row gap-2 rounded-lg bg-zinc-900 p-2 text-left hover:bg-zinc-800 sm:w-[208px]"></div>
<div className="flex h-[60px] w-full cursor-pointer flex-row gap-2 rounded-lg bg-zinc-900 p-2 text-left hover:bg-zinc-800 sm:w-[208px]"></div>
</div>
)
}

View file

@ -0,0 +1,59 @@
'use client'
import { useActions, useUIState } from 'ai/rsc'
import type { AI } from '@/lib/chat/actions'
interface Stock {
symbol: string
price: number
delta: number
}
export function Stocks({ props: stocks }: { props: Stock[] }) {
const [, setMessages] = useUIState<typeof AI>()
const { submitUserMessage } = useActions()
return (
<div className="mb-4 flex flex-col gap-2 overflow-y-scroll pb-4 text-sm sm:flex-row">
{stocks.map(stock => (
<button
key={stock.symbol}
className="flex cursor-pointer flex-row gap-2 rounded-lg bg-zinc-800 p-2 text-left hover:bg-zinc-700 sm:w-52"
onClick={async () => {
const response = await submitUserMessage(`View ${stock.symbol}`)
setMessages(currentMessages => [...currentMessages, response])
}}
>
<div
className={`text-xl ${
stock.delta > 0 ? 'text-green-600' : 'text-red-600'
} flex w-11 flex-row justify-center rounded-md bg-white/10 p-2`}
>
{stock.delta > 0 ? '↑' : '↓'}
</div>
<div className="flex flex-col">
<div className="bold uppercase text-zinc-300">{stock.symbol}</div>
<div className="text-base text-zinc-500">${stock.price}</div>
</div>
<div className="ml-auto flex flex-col">
<div
className={`${
stock.delta > 0 ? 'text-green-600' : 'text-red-600'
} bold text-right uppercase`}
>
{` ${((stock.delta / stock.price) * 100).toFixed(2)}%`}
</div>
<div
className={`${
stock.delta > 0 ? 'text-green-700' : 'text-red-700'
} text-right text-base`}
>
{stock.delta}
</div>
</div>
</button>
))}
</div>
)
}

View file

@ -1,10 +1,10 @@
'use client' "use client"
import * as React from 'react' import * as React from "react"
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button' import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root const AlertDialog = AlertDialogPrimitive.Root
@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Overlay
className={cn( className={cn(
'fixed inset-0 z-50 bg-background/80 backdrop-blur-sm 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/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className
)} )}
{...props} {...props}
@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg', "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className className
)} )}
{...props} {...props}
@ -51,13 +51,13 @@ const AlertDialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
'flex flex-col space-y-2 text-center sm:text-left', "flex flex-col space-y-2 text-center sm:text-left",
className className
)} )}
{...props} {...props}
/> />
) )
AlertDialogHeader.displayName = 'AlertDialogHeader' AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({ const AlertDialogFooter = ({
className, className,
@ -65,13 +65,13 @@ const AlertDialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className className
)} )}
{...props} {...props}
/> />
) )
AlertDialogFooter.displayName = 'AlertDialogFooter' AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef< const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>, React.ElementRef<typeof AlertDialogPrimitive.Title>,
@ -79,7 +79,7 @@ const AlertDialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title <AlertDialogPrimitive.Title
ref={ref} ref={ref}
className={cn('text-lg font-semibold', className)} className={cn("text-lg font-semibold", className)}
{...props} {...props}
/> />
)) ))
@ -91,7 +91,7 @@ const AlertDialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description <AlertDialogPrimitive.Description
ref={ref} ref={ref}
className={cn('text-sm text-muted-foreground', className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ))
@ -117,8 +117,8 @@ const AlertDialogCancel = React.forwardRef<
<AlertDialogPrimitive.Cancel <AlertDialogPrimitive.Cancel
ref={ref} ref={ref}
className={cn( className={cn(
buttonVariants({ variant: 'outline' }), buttonVariants({ variant: "outline" }),
'mt-2 sm:mt-0', "mt-2 sm:mt-0",
className className
)} )}
{...props} {...props}
@ -137,5 +137,5 @@ export {
AlertDialogTitle, AlertDialogTitle,
AlertDialogDescription, AlertDialogDescription,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel AlertDialogCancel,
} }

View file

@ -1,25 +1,25 @@
import * as React from 'react' import * as React from "react"
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from "class-variance-authority"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const badgeVariants = cva( 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', "inline-flex items-center rounded-md 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: { variants: {
variant: { variant: {
default: default:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary: secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: 'text-foreground' outline: "text-foreground",
} },
}, },
defaultVariants: { defaultVariants: {
variant: 'default' variant: "default",
} },
} }
) )

View file

@ -5,26 +5,26 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const buttonVariants = cva( const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium shadow ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{ {
variants: { variants: {
variant: { variant: {
default: default:
'bg-primary text-primary-foreground shadow-md hover:bg-primary/90', 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90', 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: outline:
'border border-input hover:bg-accent hover:text-accent-foreground', 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80', 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'shadow-none hover:bg-accent hover:text-accent-foreground', ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 shadow-none hover:underline' link: 'text-primary underline-offset-4 hover:underline'
}, },
size: { size: {
default: 'h-8 px-4 py-2', default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3', sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-11 rounded-md px-8', lg: 'h-10 rounded-md px-8',
icon: 'size-8 p-0' icon: 'size-9'
} }
}, },
defaultVariants: { defaultVariants: {

View file

@ -129,7 +129,7 @@ const CodeBlock: FC<Props> = memo(({ language, value }) => {
padding: '1.5rem 1rem' padding: '1.5rem 1rem'
}} }}
lineNumberStyle={{ lineNumberStyle={{
userSelect: "none", userSelect: 'none'
}} }}
codeTagProps={{ codeTagProps={{
style: { style: {

View file

@ -1,10 +1,10 @@
'use client' "use client"
import * as React from 'react' import * as React from "react"
import * as DialogPrimitive from '@radix-ui/react-dialog' import * as DialogPrimitive from "@radix-ui/react-dialog"
import { Cross2Icon } from "@radix-ui/react-icons"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
import { IconClose } from '@/components/ui/icons'
const Dialog = DialogPrimitive.Root const Dialog = DialogPrimitive.Root
@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
'fixed inset-0 z-50 bg-background/80 backdrop-blur-sm 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/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className
)} )}
{...props} {...props}
@ -38,14 +38,14 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg', "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className className
)} )}
{...props} {...props}
> >
{children} {children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<IconClose className="size-4" /> <Cross2Icon className="size-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
@ -59,13 +59,13 @@ const DialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left', "flex flex-col space-y-1.5 text-center sm:text-left",
className className
)} )}
{...props} {...props}
/> />
) )
DialogHeader.displayName = 'DialogHeader' DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({ const DialogFooter = ({
className, className,
@ -73,13 +73,13 @@ const DialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className className
)} )}
{...props} {...props}
/> />
) )
DialogFooter.displayName = 'DialogFooter' DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef< const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>, React.ElementRef<typeof DialogPrimitive.Title>,
@ -88,7 +88,7 @@ const DialogTitle = React.forwardRef<
<DialogPrimitive.Title <DialogPrimitive.Title
ref={ref} ref={ref}
className={cn( className={cn(
'text-lg font-semibold leading-none tracking-tight', "text-lg font-semibold leading-none tracking-tight",
className className
)} )}
{...props} {...props}
@ -102,7 +102,7 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Description <DialogPrimitive.Description
ref={ref} ref={ref}
className={cn('text-sm text-muted-foreground', className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ))
@ -112,11 +112,11 @@ export {
Dialog, Dialog,
DialogPortal, DialogPortal,
DialogOverlay, DialogOverlay,
DialogClose,
DialogTrigger, DialogTrigger,
DialogClose,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogFooter, DialogFooter,
DialogTitle, DialogTitle,
DialogDescription DialogDescription,
} }

View file

@ -1,9 +1,14 @@
'use client' "use client"
import * as React from 'react' import * as React from "react"
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import {
CheckIcon,
ChevronRightIcon,
DotFilledIcon,
} from "@radix-ui/react-icons"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root const DropdownMenu = DropdownMenuPrimitive.Root
@ -17,6 +22,28 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef< const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
@ -24,7 +51,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
ref={ref} ref={ref}
className={cn( className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1', "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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",
className className
)} )}
{...props} {...props}
@ -42,7 +69,8 @@ const DropdownMenuContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow animate-in 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] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"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",
className className
)} )}
{...props} {...props}
@ -60,8 +88,8 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && 'pl-8', inset && "pl-8",
className className
)} )}
{...props} {...props}
@ -69,6 +97,52 @@ const DropdownMenuItem = React.forwardRef<
)) ))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<DotFilledIcon className="size-4 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef< const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>, React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
@ -78,8 +152,8 @@ const DropdownMenuLabel = React.forwardRef<
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
ref={ref} ref={ref}
className={cn( className={cn(
'px-2 py-1.5 text-sm font-semibold', "px-2 py-1.5 text-sm font-semibold",
inset && 'pl-8', inset && "pl-8",
className className
)} )}
{...props} {...props}
@ -93,7 +167,7 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
ref={ref} ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)} className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} {...props}
/> />
)) ))
@ -105,18 +179,20 @@ const DropdownMenuShortcut = ({
}: React.HTMLAttributes<HTMLSpanElement>) => { }: React.HTMLAttributes<HTMLSpanElement>) => {
return ( return (
<span <span
className={cn('ml-auto text-xs tracking-widest opacity-60', className)} className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props} {...props}
/> />
) )
} }
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut' DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export { export {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuShortcut, DropdownMenuShortcut,
@ -124,5 +200,6 @@ export {
DropdownMenuPortal, DropdownMenuPortal,
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuRadioGroup DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
} }

View file

@ -1,6 +1,6 @@
import * as React from 'react' import * as React from "react"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
export interface InputProps export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {} extends React.InputHTMLAttributes<HTMLInputElement> {}
@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input <input
type={type} type={type}
className={cn( className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className className
)} )}
ref={ref} ref={ref}
@ -20,6 +20,6 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
) )
} }
) )
Input.displayName = 'Input' Input.displayName = "Input"
export { Input } export { Input }

View file

@ -1,14 +1,15 @@
'use client' "use client"
import * as React from 'react' import * as React from "react"
import * as SelectPrimitive from '@radix-ui/react-select'
import { cn } from '@/lib/utils'
import { import {
IconArrowDown, CaretSortIcon,
IconCheck, CheckIcon,
IconChevronUpDown ChevronDownIcon,
} from '@/components/ui/icons' ChevronUpIcon,
} from "@radix-ui/react-icons"
import * as SelectPrimitive from "@radix-ui/react-select"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root const Select = SelectPrimitive.Root
@ -23,43 +24,81 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
'flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className className
)} )}
{...props} {...props}
> >
{children} {children}
<SelectPrimitive.Icon asChild> <SelectPrimitive.Icon asChild>
<IconChevronUpDown className="opacity-50" /> <CaretSortIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
)) ))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef< const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>, React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => ( >(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal> <SelectPrimitive.Portal>
<SelectPrimitive.Content <SelectPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-80', "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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",
position === 'popper' && 'translate-y-1', position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className className
)} )}
position={position} position={position}
{...props} {...props}
> >
<SelectScrollUpButton />
<SelectPrimitive.Viewport <SelectPrimitive.Viewport
className={cn( className={cn(
'p-1', "p-1",
position === 'popper' && position === "popper" &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]' "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)} )}
> >
{children} {children}
</SelectPrimitive.Viewport> </SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
)) ))
@ -71,7 +110,7 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SelectPrimitive.Label <SelectPrimitive.Label
ref={ref} ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)} className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props} {...props}
/> />
)) ))
@ -84,14 +123,14 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item <SelectPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className className
)} )}
{...props} {...props}
> >
<span className="absolute left-2 flex size-3.5 items-center justify-center"> <span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator> <SelectPrimitive.ItemIndicator>
<IconCheck className="size-4" /> <CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator> </SelectPrimitive.ItemIndicator>
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
@ -105,7 +144,7 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SelectPrimitive.Separator <SelectPrimitive.Separator
ref={ref} ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)} className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} {...props}
/> />
)) ))
@ -119,5 +158,7 @@ export {
SelectContent, SelectContent,
SelectLabel, SelectLabel,
SelectItem, SelectItem,
SelectSeparator SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
} }

View file

@ -1,16 +1,16 @@
'use client' "use client"
import * as React from 'react' import * as React from "react"
import * as SeparatorPrimitive from '@radix-ui/react-separator' import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const Separator = React.forwardRef< const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>, React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>( >(
( (
{ className, orientation = 'horizontal', decorative = true, ...props }, { className, orientation = "horizontal", decorative = true, ...props },
ref ref
) => ( ) => (
<SeparatorPrimitive.Root <SeparatorPrimitive.Root
@ -18,8 +18,8 @@ const Separator = React.forwardRef<
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
'shrink-0 bg-border', "shrink-0 bg-border",
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]', orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className className
)} )}
{...props} {...props}

View file

@ -1,11 +1,11 @@
'use client' "use client"
import * as React from 'react' import * as React from "react"
import * as SheetPrimitive from '@radix-ui/react-dialog' import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from 'class-variance-authority' import { Cross2Icon } from "@radix-ui/react-icons"
import { IconClose } from '@/components/ui/icons' import { cva, type VariantProps } from "class-variance-authority"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root const Sheet = SheetPrimitive.Root
@ -21,7 +21,7 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
className={cn( className={cn(
'fixed inset-0 z-50 bg-background/80 backdrop-blur-sm 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/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className
)} )}
{...props} {...props}
@ -31,21 +31,21 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva( const sheetVariants = cva(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500', "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{ {
variants: { variants: {
side: { side: {
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom: bottom:
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm', left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right: right:
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm' "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
} },
}, },
defaultVariants: { defaultVariants: {
side: 'right' side: "right",
} },
} }
) )
@ -56,7 +56,7 @@ interface SheetContentProps
const SheetContent = React.forwardRef< const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>, React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps SheetContentProps
>(({ side = 'left', className, children, ...props }, ref) => ( >(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal> <SheetPortal>
<SheetOverlay /> <SheetOverlay />
<SheetPrimitive.Content <SheetPrimitive.Content
@ -66,7 +66,7 @@ const SheetContent = React.forwardRef<
> >
{children} {children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<IconClose className="size-4" /> <Cross2Icon className="size-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</SheetPrimitive.Close> </SheetPrimitive.Close>
</SheetPrimitive.Content> </SheetPrimitive.Content>
@ -80,13 +80,13 @@ const SheetHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
'flex flex-col space-y-2 text-center sm:text-left', "flex flex-col space-y-2 text-center sm:text-left",
className className
)} )}
{...props} {...props}
/> />
) )
SheetHeader.displayName = 'SheetHeader' SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({ const SheetFooter = ({
className, className,
@ -94,13 +94,13 @@ const SheetFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className className
)} )}
{...props} {...props}
/> />
) )
SheetFooter.displayName = 'SheetFooter' SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef< const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>, React.ElementRef<typeof SheetPrimitive.Title>,
@ -108,7 +108,7 @@ const SheetTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Title <SheetPrimitive.Title
ref={ref} ref={ref}
className={cn('text-lg font-semibold text-foreground', className)} className={cn("text-lg font-semibold text-foreground", className)}
{...props} {...props}
/> />
)) ))
@ -120,7 +120,7 @@ const SheetDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Description <SheetPrimitive.Description
ref={ref} ref={ref}
className={cn('text-sm text-muted-foreground', className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ))
@ -136,5 +136,5 @@ export {
SheetHeader, SheetHeader,
SheetFooter, SheetFooter,
SheetTitle, SheetTitle,
SheetDescription SheetDescription,
} }

31
components/ui/sonner.tsx Normal file
View file

@ -0,0 +1,31 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

View file

@ -1,9 +1,9 @@
'use client' "use client"
import * as React from 'react' import * as React from "react"
import * as SwitchPrimitives from '@radix-ui/react-switch' import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const Switch = React.forwardRef< const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>, React.ElementRef<typeof SwitchPrimitives.Root>,
@ -11,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SwitchPrimitives.Root <SwitchPrimitives.Root
className={cn( className={cn(
'peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input', "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className className
)} )}
{...props} {...props}
@ -19,7 +19,7 @@ const Switch = React.forwardRef<
> >
<SwitchPrimitives.Thumb <SwitchPrimitives.Thumb
className={cn( className={cn(
'pointer-events-none block size-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0' "pointer-events-none block size-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)} )}
/> />
</SwitchPrimitives.Root> </SwitchPrimitives.Root>

View file

@ -1,6 +1,6 @@
import * as React from 'react' import * as React from "react"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
export interface TextareaProps export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {} extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return ( return (
<textarea <textarea
className={cn( className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', "flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className className
)} )}
ref={ref} ref={ref}
@ -19,6 +19,6 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
) )
} }
) )
Textarea.displayName = 'Textarea' Textarea.displayName = "Textarea"
export { Textarea } export { Textarea }

View file

@ -1,9 +1,9 @@
'use client' "use client"
import * as React from 'react' import * as React from "react"
import * as TooltipPrimitive from '@radix-ui/react-tooltip' import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider const TooltipProvider = TooltipPrimitive.Provider
@ -19,7 +19,7 @@ const TooltipContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-xs font-medium text-popover-foreground shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1', "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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",
className className
)} )}
{...props} {...props}

View file

@ -1,8 +1,4 @@
'use client' import { type Session } from '@/lib/types'
import Image from 'next/image'
import { type Session } from 'next-auth'
import { signOut } from 'next-auth/react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@ -12,7 +8,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { IconExternalLink } from '@/components/ui/icons' import { signOut } from '@/auth'
export interface UserMenuProps { export interface UserMenuProps {
user: Session['user'] user: Session['user']
@ -29,49 +25,27 @@ export function UserMenu({ user }: UserMenuProps) {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="pl-0"> <Button variant="ghost" className="pl-0">
{user?.image ? ( <div className="flex size-7 shrink-0 select-none items-center justify-center rounded-full bg-muted/50 text-xs font-medium uppercase text-muted-foreground">
<Image {getUserInitials(user.email)}
className="size-6 transition-opacity duration-300 rounded-full select-none ring-1 ring-zinc-100/10 hover:opacity-80"
src={user?.image ? `${user.image}&s=60` : ''}
alt={user.name ?? 'Avatar'}
height={48}
width={48}
/>
) : (
<div className="flex items-center justify-center text-xs font-medium uppercase rounded-full select-none size-7 shrink-0 bg-muted/50 text-muted-foreground">
{user?.name ? getUserInitials(user?.name) : null}
</div> </div>
)} <span className="ml-2 hidden md:block">{user.email}</span>
<span className="ml-2">{user?.name}</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent sideOffset={8} align="start" className="w-[180px]"> <DropdownMenuContent sideOffset={8} align="start" className="w-fit">
<DropdownMenuItem className="flex-col items-start"> <DropdownMenuItem className="flex-col items-start">
<div className="text-xs font-medium">{user?.name}</div> <div className="text-xs text-zinc-500">{user.email}</div>
<div className="text-xs text-zinc-500">{user?.email}</div>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem asChild> <form
<a action={async () => {
href="https://vercel.com" 'use server'
target="_blank" await signOut()
rel="noopener noreferrer" }}
className="inline-flex items-center justify-between w-full text-xs"
> >
Vercel Homepage <button className=" relative flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none transition-colors hover:bg-red-500 hover:text-white focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50">
<IconExternalLink className="size-3 ml-auto" /> Sign Out
</a> </button>
</DropdownMenuItem> </form>
<DropdownMenuItem
onClick={() =>
signOut({
callbackUrl: '/'
})
}
className="text-xs"
>
Log Out
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>

509
lib/chat/actions.tsx Normal file
View file

@ -0,0 +1,509 @@
import 'server-only'
import {
createAI,
createStreamableUI,
getMutableAIState,
getAIState,
render,
createStreamableValue
} from 'ai/rsc'
import OpenAI from 'openai'
import {
spinner,
BotCard,
BotMessage,
SystemMessage,
Stock,
Purchase
} from '@/components/stocks'
import { z } from 'zod'
import { EventsSkeleton } from '@/components/stocks/events-skeleton'
import { Events } from '@/components/stocks/event'
import { StocksSkeleton } from '@/components/stocks/stocks-skeleton'
import { Stocks } from '@/components/stocks/stocks'
import { StockSkeleton } from '@/components/stocks/stock-skeleton'
import {
formatNumber,
runAsyncFnWithoutBlocking,
sleep,
nanoid
} from '@/lib/utils'
import { saveChat } from '@/app/actions'
import { SpinnerMessage, UserMessage } from '@/components/stocks/message'
import { Chat } from '@/lib/types'
import { auth } from '@/auth'
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY || ''
})
async function confirmPurchase(symbol: string, price: number, amount: number) {
'use server'
const aiState = getMutableAIState<typeof AI>()
const purchasing = createStreamableUI(
<div className="inline-flex items-start gap-1 md:items-center">
{spinner}
<p className="mb-2">
Purchasing {amount} ${symbol}...
</p>
</div>
)
const systemMessage = createStreamableUI(null)
runAsyncFnWithoutBlocking(async () => {
await sleep(1000)
purchasing.update(
<div className="inline-flex items-start gap-1 md:items-center">
{spinner}
<p className="mb-2">
Purchasing {amount} ${symbol}... working on it...
</p>
</div>
)
await sleep(1000)
purchasing.done(
<div>
<p className="mb-2">
You have successfully purchased {amount} ${symbol}. Total cost:{' '}
{formatNumber(amount * price)}
</p>
</div>
)
systemMessage.done(
<SystemMessage>
You have purchased {amount} shares of {symbol} at ${price}. Total cost ={' '}
{formatNumber(amount * price)}.
</SystemMessage>
)
aiState.done({
...aiState.get(),
messages: [
...aiState.get().messages.slice(0, -1),
{
id: nanoid(),
role: 'function',
name: 'showStockPurchase',
content: JSON.stringify({
name: symbol,
price,
defaultAmount: amount,
status: 'completed'
})
},
{
id: nanoid(),
role: 'system',
content: `[User has purchased ${amount} shares of ${symbol} at ${price}. Total cost = ${
amount * price
}]`
}
]
})
})
return {
purchasingUI: purchasing.value,
newMessage: {
id: nanoid(),
display: systemMessage.value
}
}
}
async function submitUserMessage(content: string) {
'use server'
const aiState = getMutableAIState<typeof AI>()
aiState.update({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: nanoid(),
role: 'user',
content
}
]
})
let textStream: undefined | ReturnType<typeof createStreamableValue<string>>
let textNode: undefined | React.ReactNode
const ui = render({
model: 'gpt-3.5-turbo',
provider: openai,
initial: <SpinnerMessage />,
messages: [
{
role: 'system',
content: `\
You are a stock trading conversation bot and you can help users buy stocks, step by step.
You and the user can discuss stock prices and the user can adjust the amount of stocks they want to buy, or place an order, in the UI.
Messages inside [] means that it's a UI element or a user event. For example:
- "[Price of AAPL = 100]" means that an interface of the stock price of AAPL is shown to the user.
- "[User has changed the amount of AAPL to 10]" means that the user has changed the amount of AAPL to 10 in the UI.
If the user requests purchasing a stock, call \`show_stock_purchase_ui\` to show the purchase UI.
If the user just wants the price, call \`show_stock_price\` to show the price.
If you want to show trending stocks, call \`list_stocks\`.
If you want to show events, call \`get_events\`.
If the user wants to sell stock, or complete another impossible task, respond that you are a demo and cannot do that.
Besides that, you can also chat with users and do some calculations if needed.`
},
...aiState.get().messages.map((message: any) => ({
role: message.role,
content: message.content,
name: message.name
}))
],
text: ({ content, done, delta }) => {
if (!textStream) {
textStream = createStreamableValue('')
textNode = <BotMessage content={textStream.value} />
}
if (done) {
textStream.done()
aiState.done({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: nanoid(),
role: 'assistant',
content
}
]
})
} else {
textStream.update(delta)
}
return textNode
},
functions: {
listStocks: {
description: 'List three imaginary stocks that are trending.',
parameters: z.object({
stocks: z.array(
z.object({
symbol: z.string().describe('The symbol of the stock'),
price: z.number().describe('The price of the stock'),
delta: z.number().describe('The change in price of the stock')
})
)
}),
render: async function* ({ stocks }) {
yield (
<BotCard>
<StocksSkeleton />
</BotCard>
)
await sleep(1000)
aiState.done({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: nanoid(),
role: 'function',
name: 'listStocks',
content: JSON.stringify(stocks)
}
]
})
return (
<BotCard>
<Stocks props={stocks} />
</BotCard>
)
}
},
showStockPrice: {
description:
'Get the current stock price of a given stock or currency. Use this to show the price to the user.',
parameters: z.object({
symbol: z
.string()
.describe(
'The name or symbol of the stock or currency. e.g. DOGE/AAPL/USD.'
),
price: z.number().describe('The price of the stock.'),
delta: z.number().describe('The change in price of the stock')
}),
render: async function* ({ symbol, price, delta }) {
yield (
<BotCard>
<StockSkeleton />
</BotCard>
)
await sleep(1000)
aiState.done({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: nanoid(),
role: 'function',
name: 'showStockPrice',
content: JSON.stringify({ symbol, price, delta })
}
]
})
return (
<BotCard>
<Stock props={{ symbol, price, delta }} />
</BotCard>
)
}
},
showStockPurchase: {
description:
'Show price and the UI to purchase a stock or currency. Use this if the user wants to purchase a stock or currency.',
parameters: z.object({
symbol: z
.string()
.describe(
'The name or symbol of the stock or currency. e.g. DOGE/AAPL/USD.'
),
price: z.number().describe('The price of the stock.'),
numberOfShares: z
.number()
.describe(
'The **number of shares** for a stock or currency to purchase. Can be optional if the user did not specify it.'
)
}),
render: async function* ({ symbol, price, numberOfShares = 100 }) {
if (numberOfShares <= 0 || numberOfShares > 1000) {
aiState.done({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: nanoid(),
role: 'system',
content: `[User has selected an invalid amount]`
}
]
})
return <BotMessage content={'Invalid amount'} />
}
aiState.done({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: nanoid(),
role: 'function',
name: 'showStockPurchase',
content: JSON.stringify({
symbol,
price,
numberOfShares
})
}
]
})
return (
<>
<BotMessage
content={`Sure!
${
typeof numberOfShares === 'number'
? `Click the button below to purchase ${numberOfShares} shares of $${symbol}:`
: `How many $${symbol} would you like to purchase?`
}`}
/>
<Purchase
props={{
defaultAmount: numberOfShares,
name: symbol,
price: +price,
status: 'requires_action'
}}
/>
</>
)
}
},
getEvents: {
description:
'List funny imaginary events between user highlighted dates that describe stock activity.',
parameters: z.object({
events: z.array(
z.object({
date: z
.string()
.describe('The date of the event, in ISO-8601 format'),
headline: z.string().describe('The headline of the event'),
description: z.string().describe('The description of the event')
})
)
}),
render: async function* ({ events }) {
yield (
<BotCard>
<EventsSkeleton />
</BotCard>
)
await sleep(1000)
aiState.done({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: nanoid(),
role: 'function',
name: 'getEvents',
content: JSON.stringify(events)
}
]
})
return (
<BotCard>
<Events props={events} />
</BotCard>
)
}
}
}
})
return {
id: nanoid(),
display: ui
}
}
export type Message = {
role: 'user' | 'assistant' | 'system' | 'function' | 'data' | 'tool'
content: string
id?: string
name?: string
}
export type AIState = {
chatId: string
messages: {
role: 'user' | 'assistant' | 'system' | 'function' | 'data' | 'tool'
content: string
id: string
name?: string
}[]
}
export type UIState = {
id: string
display: React.ReactNode
}[]
export const AI = createAI<AIState, UIState>({
actions: {
submitUserMessage,
confirmPurchase
},
initialUIState: [],
initialAIState: { chatId: nanoid(), messages: [] },
unstable_onGetUIState: async () => {
'use server'
const session = await auth()
if (session && session.user) {
const aiState = getAIState()
if (aiState) {
const uiState = getUIStateFromAIState(aiState)
return uiState
}
} else {
return
}
},
unstable_onSetAIState: async ({ state }) => {
'use server'
const session = await auth()
if (session && session.user) {
const { chatId, messages } = state
const createdAt = new Date()
const userId = session.user.id as string
const path = `/chat/${chatId}`
const title = messages[0].content.substring(0, 100)
const chat: Chat = {
id: chatId,
title,
userId,
createdAt,
messages,
path
}
await saveChat(chat)
} else {
return
}
}
})
export const getUIStateFromAIState = (aiState: Chat) => {
return aiState.messages
.filter(message => message.role !== 'system')
.map((message, index) => ({
id: `${aiState.chatId}-${index}`,
display:
message.role === 'function' ? (
message.name === 'listStocks' ? (
<BotCard>
<Stocks props={JSON.parse(message.content)} />
</BotCard>
) : message.name === 'showStockPrice' ? (
<BotCard>
<Stock props={JSON.parse(message.content)} />
</BotCard>
) : message.name === 'showStockPurchase' ? (
<BotCard>
<Purchase props={JSON.parse(message.content)} />
</BotCard>
) : message.name === 'getEvents' ? (
<BotCard>
<Events props={JSON.parse(message.content)} />
</BotCard>
) : null
) : message.role === 'user' ? (
<UserMessage>{message.content}</UserMessage>
) : (
<BotMessage content={message.content} />
)
}))
}

View file

@ -0,0 +1,26 @@
import { StreamableValue, readStreamableValue } from 'ai/rsc'
import { useEffect, useState } from 'react'
export const useStreamableText = (
content: string | StreamableValue<string>
) => {
const [rawContent, setRawContent] = useState(
typeof content === 'string' ? content : ''
)
useEffect(() => {
;(async () => {
if (typeof content === 'object') {
let value = ''
for await (const delta of readStreamableValue(content)) {
console.log(delta)
if (typeof delta === 'string') {
setRawContent((value = value + delta))
}
}
}
})()
}, [content])
return rawContent
}

View file

@ -1,4 +1,4 @@
import { type Message } from 'ai' import { Message } from 'ai'
export interface Chat extends Record<string, any> { export interface Chat extends Record<string, any> {
id: string id: string
@ -16,3 +16,15 @@ export type ServerActionResult<Result> = Promise<
error: string error: string
} }
> >
export interface Session {
user: {
id: string
email: string
}
}
export interface AuthResult {
type: string
message: string
}

View file

@ -41,3 +41,23 @@ export function formatDate(input: string | number | Date): string {
year: 'numeric' year: 'numeric'
}) })
} }
export const formatNumber = (value: number) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(value)
export const runAsyncFnWithoutBlocking = (
fn: (...args: any) => Promise<any>
) => {
fn()
}
export const sleep = (ms: number) =>
new Promise(resolve => setTimeout(resolve, ms))
export const getStringFromBuffer = (buffer: ArrayBuffer) =>
Array.from(new Uint8Array(buffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('')

View file

@ -1,5 +1,8 @@
export { auth as middleware } from './auth' import NextAuth from 'next-auth'
import { authConfig } from './auth.config'
export default NextAuth(authConfig).auth
export const config = { export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'] matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)']
} }

View file

@ -7,6 +7,7 @@
"lint": "next lint", "lint": "next lint",
"lint:fix": "next lint --fix", "lint:fix": "next lint --fix",
"preview": "next build && next start", "preview": "next build && next start",
"seed": "node -r dotenv/config ./scripts/seed.mjs",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"format:write": "prettier --write \"{app,lib,components}/**/*.{ts,tsx,mdx}\" --cache", "format:write": "prettier --write \"{app,lib,components}/**/*.{ts,tsx,mdx}\" --cache",
"format:check": "prettier --check \"{app,lib,components}**/*.{ts,tsx,mdx}\" --cache" "format:check": "prettier --check \"{app,lib,components}**/*.{ts,tsx,mdx}\" --cache"
@ -15,6 +16,7 @@
"@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0", "@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-separator": "^1.0.3",
@ -24,29 +26,35 @@
"@vercel/analytics": "^1.1.2", "@vercel/analytics": "^1.1.2",
"@vercel/kv": "^1.0.1", "@vercel/kv": "^1.0.1",
"@vercel/og": "^0.6.2", "@vercel/og": "^0.6.2",
"ai": "^2.2.31", "@vercel/postgres": "^0.7.2",
"ai": "^3.0.12",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"d3-scale": "^4.0.2",
"date-fns": "^3.3.1",
"focus-trap-react": "^10.2.3", "focus-trap-react": "^10.2.3",
"framer-motion": "^10.18.0", "framer-motion": "^10.18.0",
"geist": "^1.2.1", "geist": "^1.2.1",
"nanoid": "^5.0.4", "nanoid": "^5.0.4",
"next": "14.1.0", "next": "14.2.0-canary.6",
"next-auth": "5.0.0-beta.4", "next-auth": "5.0.0-beta.4",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"openai": "^4.24.7", "openai": "^4.24.7",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-intersection-observer": "^9.5.3", "react-intersection-observer": "^9.5.3",
"react-markdown": "^8.0.7", "react-markdown": "^8.0.7",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
"react-textarea-autosize": "^8.5.3", "react-textarea-autosize": "^8.5.3",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"remark-math": "^5.1.1" "remark-math": "^5.1.1",
"sonner": "^1.4.3",
"usehooks-ts": "^2.16.0",
"zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.10", "@tailwindcss/typography": "^0.5.10",
"@types/d3-scale": "^4.0.8",
"@types/node": "^20.11.5", "@types/node": "^20.11.5",
"@types/react": "^18.2.48", "@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",

398
pnpm-lock.yaml generated
View file

@ -14,6 +14,9 @@ dependencies:
'@radix-ui/react-dropdown-menu': '@radix-ui/react-dropdown-menu':
specifier: ^2.0.6 specifier: ^2.0.6
version: 2.0.6(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0) version: 2.0.6(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-icons':
specifier: ^1.3.0
version: 1.3.0(react@18.2.0)
'@radix-ui/react-label': '@radix-ui/react-label':
specifier: ^2.0.2 specifier: ^2.0.2
version: 2.0.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0) version: 2.0.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0)
@ -41,15 +44,24 @@ dependencies:
'@vercel/og': '@vercel/og':
specifier: ^0.6.2 specifier: ^0.6.2
version: 0.6.2 version: 0.6.2
'@vercel/postgres':
specifier: ^0.7.2
version: 0.7.2
ai: ai:
specifier: ^2.2.31 specifier: ^3.0.12
version: 2.2.31(react@18.2.0)(solid-js@1.8.11)(svelte@4.2.9)(vue@3.4.15) version: 3.0.12(react@18.2.0)(solid-js@1.8.11)(svelte@4.2.9)(vue@3.4.15)(zod@3.22.4)
class-variance-authority: class-variance-authority:
specifier: ^0.7.0 specifier: ^0.7.0
version: 0.7.0 version: 0.7.0
clsx: clsx:
specifier: ^2.1.0 specifier: ^2.1.0
version: 2.1.0 version: 2.1.0
d3-scale:
specifier: ^4.0.2
version: 4.0.2
date-fns:
specifier: ^3.3.1
version: 3.3.1
focus-trap-react: focus-trap-react:
specifier: ^10.2.3 specifier: ^10.2.3
version: 10.2.3(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0) version: 10.2.3(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0)
@ -58,19 +70,19 @@ dependencies:
version: 10.18.0(react-dom@18.2.0)(react@18.2.0) version: 10.18.0(react-dom@18.2.0)(react@18.2.0)
geist: geist:
specifier: ^1.2.1 specifier: ^1.2.1
version: 1.2.1(next@14.1.0) version: 1.2.1(next@14.2.0-canary.6)
nanoid: nanoid:
specifier: ^5.0.4 specifier: ^5.0.4
version: 5.0.4 version: 5.0.4
next: next:
specifier: 14.1.0 specifier: 14.2.0-canary.6
version: 14.1.0(react-dom@18.2.0)(react@18.2.0) version: 14.2.0-canary.6(react-dom@18.2.0)(react@18.2.0)
next-auth: next-auth:
specifier: 5.0.0-beta.4 specifier: 5.0.0-beta.4
version: 5.0.0-beta.4(next@14.1.0)(react@18.2.0) version: 5.0.0-beta.4(next@14.2.0-canary.6)(react@18.2.0)
next-themes: next-themes:
specifier: ^0.2.1 specifier: ^0.2.1
version: 0.2.1(next@14.1.0)(react-dom@18.2.0)(react@18.2.0) version: 0.2.1(next@14.2.0-canary.6)(react-dom@18.2.0)(react@18.2.0)
openai: openai:
specifier: ^4.24.7 specifier: ^4.24.7
version: 4.24.7 version: 4.24.7
@ -80,9 +92,6 @@ dependencies:
react-dom: react-dom:
specifier: ^18.2.0 specifier: ^18.2.0
version: 18.2.0(react@18.2.0) version: 18.2.0(react@18.2.0)
react-hot-toast:
specifier: ^2.4.1
version: 2.4.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0)
react-intersection-observer: react-intersection-observer:
specifier: ^9.5.3 specifier: ^9.5.3
version: 9.5.3(react@18.2.0) version: 9.5.3(react@18.2.0)
@ -101,11 +110,23 @@ dependencies:
remark-math: remark-math:
specifier: ^5.1.1 specifier: ^5.1.1
version: 5.1.1 version: 5.1.1
sonner:
specifier: ^1.4.3
version: 1.4.3(react-dom@18.2.0)(react@18.2.0)
usehooks-ts:
specifier: ^2.16.0
version: 2.16.0(react@18.2.0)
zod:
specifier: ^3.22.4
version: 3.22.4
devDependencies: devDependencies:
'@tailwindcss/typography': '@tailwindcss/typography':
specifier: ^0.5.10 specifier: ^0.5.10
version: 0.5.10(tailwindcss@3.4.1) version: 0.5.10(tailwindcss@3.4.1)
'@types/d3-scale':
specifier: ^4.0.8
version: 4.0.8
'@types/node': '@types/node':
specifier: ^20.11.5 specifier: ^20.11.5
version: 20.11.5 version: 20.11.5
@ -360,8 +381,14 @@ packages:
'@jridgewell/resolve-uri': 3.1.1 '@jridgewell/resolve-uri': 3.1.1
'@jridgewell/sourcemap-codec': 1.4.15 '@jridgewell/sourcemap-codec': 1.4.15
/@next/env@14.1.0: /@neondatabase/serverless@0.7.2:
resolution: {integrity: sha512-Py8zIo+02ht82brwwhTg36iogzFqGLPXlRGKQw5s+qP/kMNc4MAyDeEwBKDijk6zTIbegEgu8Qy7C1LboslQAw==} resolution: {integrity: sha512-wU3WA2uTyNO7wjPs3Mg0G01jztAxUxzd9/mskMmtPwPTjf7JKWi9AW5/puOGXLxmZ9PVgRFeBVRVYq5nBPhsCg==}
dependencies:
'@types/pg': 8.6.6
dev: false
/@next/env@14.2.0-canary.6:
resolution: {integrity: sha512-GVguN9O24Rh1aFTm9CIKnNgRZjjdB6E4V3KmQTq/hjqL+IzFimBSrAPHJY7Fe9uWMxa5VD78Id8iwHVrIoch3g==}
dev: false dev: false
/@next/eslint-plugin-next@14.1.0: /@next/eslint-plugin-next@14.1.0:
@ -370,8 +397,8 @@ packages:
glob: 10.3.10 glob: 10.3.10
dev: true dev: true
/@next/swc-darwin-arm64@14.1.0: /@next/swc-darwin-arm64@14.2.0-canary.6:
resolution: {integrity: sha512-nUDn7TOGcIeyQni6lZHfzNoo9S0euXnu0jhsbMOmMJUBfgsnESdjN97kM7cBqQxZa8L/bM9om/S5/1dzCrW6wQ==} resolution: {integrity: sha512-9stpz9B29+/6NF6qdmAGLJR1bG/0WLzaMHTuPy482JHBcuJMVcYKCWzfTkw1v68/Zdkoja404BeaJTV1MRTB3g==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
@ -379,8 +406,8 @@ packages:
dev: false dev: false
optional: true optional: true
/@next/swc-darwin-x64@14.1.0: /@next/swc-darwin-x64@14.2.0-canary.6:
resolution: {integrity: sha512-1jgudN5haWxiAl3O1ljUS2GfupPmcftu2RYJqZiMJmmbBT5M1XDffjUtRUzP4W3cBHsrvkfOFdQ71hAreNQP6g==} resolution: {integrity: sha512-NE1paZ1+eIi2hyWZrgigDKyjUoy7YeN+8VCqtcxkHsOoZRir0Ugy9BIidlVxOjlYF72sHuOy082/kl5EpMs7YA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
@ -388,8 +415,8 @@ packages:
dev: false dev: false
optional: true optional: true
/@next/swc-linux-arm64-gnu@14.1.0: /@next/swc-linux-arm64-gnu@14.2.0-canary.6:
resolution: {integrity: sha512-RHo7Tcj+jllXUbK7xk2NyIDod3YcCPDZxj1WLIYxd709BQ7WuRYl3OWUNG+WUfqeQBds6kvZYlc42NJJTNi4tQ==} resolution: {integrity: sha512-1ab2OoRyev9rA8KfkLSz7uc4Qvk/BiM5zXRI3rcrXtdrOBCafZdcSxsqRgcmHYJ5DO1QLf+i5b0q/VAYhzKKQQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
@ -397,8 +424,8 @@ packages:
dev: false dev: false
optional: true optional: true
/@next/swc-linux-arm64-musl@14.1.0: /@next/swc-linux-arm64-musl@14.2.0-canary.6:
resolution: {integrity: sha512-v6kP8sHYxjO8RwHmWMJSq7VZP2nYCkRVQ0qolh2l6xroe9QjbgV8siTbduED4u0hlk0+tjS6/Tuy4n5XCp+l6g==} resolution: {integrity: sha512-QAbbtAlBx2Jbl59md7hAkLF3V5eAa22OnHwxxyHOHjpJSVQrrcvQb5u86vfdttxcypSiG0M0xiIE7PYLwPvIXw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
@ -406,8 +433,8 @@ packages:
dev: false dev: false
optional: true optional: true
/@next/swc-linux-x64-gnu@14.1.0: /@next/swc-linux-x64-gnu@14.2.0-canary.6:
resolution: {integrity: sha512-zJ2pnoFYB1F4vmEVlb/eSe+VH679zT1VdXlZKX+pE66grOgjmKJHKacf82g/sWE4MQ4Rk2FMBCRnX+l6/TVYzQ==} resolution: {integrity: sha512-O7zWKvvvjEbgzTtBTnQz4iH7ZA7RYxu0v3cUwQd3SnplInBCluaSk0lq5M9bRfHhriVW7/09cyMErY0nq+ZWlw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
@ -415,8 +442,8 @@ packages:
dev: false dev: false
optional: true optional: true
/@next/swc-linux-x64-musl@14.1.0: /@next/swc-linux-x64-musl@14.2.0-canary.6:
resolution: {integrity: sha512-rbaIYFt2X9YZBSbH/CwGAjbBG2/MrACCVu2X0+kSykHzHnYH5FjHxwXLkcoJ10cX0aWCEynpu+rP76x0914atg==} resolution: {integrity: sha512-TywXiQUsQVcQKouCJSYfi7CgziH3n+PFC7NgakwKwvvl1VDVmK7TxqkUjNOzfcgF3g/Dg0IIqzLOPqKa1Xp6uw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
@ -424,8 +451,8 @@ packages:
dev: false dev: false
optional: true optional: true
/@next/swc-win32-arm64-msvc@14.1.0: /@next/swc-win32-arm64-msvc@14.2.0-canary.6:
resolution: {integrity: sha512-o1N5TsYc8f/HpGt39OUQpQ9AKIGApd3QLueu7hXk//2xq5Z9OxmV6sQfNp8C7qYmiOlHYODOGqNNa0e9jvchGQ==} resolution: {integrity: sha512-r1m4Ugc0u00bIiYeYAC2U/TrwYOuZKWFA1I3I5qdnagoe1Bul4OijYnMkPYuhhjB6bHUUQjcUtHV5lkcnTr5uA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
@ -433,8 +460,8 @@ packages:
dev: false dev: false
optional: true optional: true
/@next/swc-win32-ia32-msvc@14.1.0: /@next/swc-win32-ia32-msvc@14.2.0-canary.6:
resolution: {integrity: sha512-XXIuB1DBRCFwNO6EEzCTMHT5pauwaSj4SWs7CYnME57eaReAKBXCnkUE80p/pAZcewm7hs+vGvNqDPacEXHVkw==} resolution: {integrity: sha512-l2+CnXMqgh6umnptBkptWk2bz+3bKJe/T5ojJlUoLweIs4eKJ3V87+CH8z5zkznqKVHZ5hQLC0JieV0OVun72Q==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [ia32] cpu: [ia32]
os: [win32] os: [win32]
@ -442,8 +469,8 @@ packages:
dev: false dev: false
optional: true optional: true
/@next/swc-win32-x64-msvc@14.1.0: /@next/swc-win32-x64-msvc@14.2.0-canary.6:
resolution: {integrity: sha512-9WEbVRRAqJ3YFVqEZIxUqkiO8l1nool1LmNxygr5HWF8AcSYsEpneUDhmjUVJEzO2A04+oPtZdombzzPPkTtgg==} resolution: {integrity: sha512-1dRrinBhP4R85NVmrxzShqNl9STmLe2ePI1LZ8HkG8RY08o570JiNEwTcbsoqCYzKi7db61xox7todeLjIleIg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@ -731,6 +758,14 @@ packages:
react-dom: 18.2.0(react@18.2.0) react-dom: 18.2.0(react@18.2.0)
dev: false dev: false
/@radix-ui/react-icons@1.3.0(react@18.2.0):
resolution: {integrity: sha512-jQxj/0LKgp+j9BiTXz3O3sgs26RNet2iLWmsPyRz2SIcR4q/4SbazXfnYwbAr+vLYKSfc7qxzyGQA1HLlYiuNw==}
peerDependencies:
react: ^16.x || ^17.x || ^18.x
dependencies:
react: 18.2.0
dev: false
/@radix-ui/react-id@1.0.1(@types/react@18.2.48)(react@18.2.0): /@radix-ui/react-id@1.0.1(@types/react@18.2.48)(react@18.2.0):
resolution: {integrity: sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==} resolution: {integrity: sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==}
peerDependencies: peerDependencies:
@ -1211,9 +1246,14 @@ packages:
string.prototype.codepointat: 0.2.1 string.prototype.codepointat: 0.2.1
dev: false dev: false
/@swc/helpers@0.5.2: /@swc/counter@0.1.3:
resolution: {integrity: sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==} resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
dev: false
/@swc/helpers@0.5.5:
resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==}
dependencies: dependencies:
'@swc/counter': 0.1.3
tslib: 2.6.2 tslib: 2.6.2
dev: false dev: false
@ -1229,12 +1269,26 @@ packages:
tailwindcss: 3.4.1 tailwindcss: 3.4.1
dev: true dev: true
/@types/d3-scale@4.0.8:
resolution: {integrity: sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==}
dependencies:
'@types/d3-time': 3.0.3
dev: true
/@types/d3-time@3.0.3:
resolution: {integrity: sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==}
dev: true
/@types/debug@4.1.12: /@types/debug@4.1.12:
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
dependencies: dependencies:
'@types/ms': 0.7.34 '@types/ms': 0.7.34
dev: false dev: false
/@types/diff-match-patch@1.0.36:
resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==}
dev: false
/@types/estree@1.0.5: /@types/estree@1.0.5:
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
dev: false dev: false
@ -1281,6 +1335,14 @@ packages:
dependencies: dependencies:
undici-types: 5.26.5 undici-types: 5.26.5
/@types/pg@8.6.6:
resolution: {integrity: sha512-O2xNmXebtwVekJDD+02udOncjVcMZQuTEQEMpKJ0ZRf5E7/9JJX3izhKUcUifBkyKpljyUM6BTgy2trmviKlpw==}
dependencies:
'@types/node': 20.11.5
pg-protocol: 1.6.0
pg-types: 2.2.0
dev: false
/@types/prop-types@15.7.11: /@types/prop-types@15.7.11:
resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==} resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==}
@ -1405,6 +1467,16 @@ packages:
yoga-wasm-web: 0.3.3 yoga-wasm-web: 0.3.3
dev: false dev: false
/@vercel/postgres@0.7.2:
resolution: {integrity: sha512-IqR/ZAvoPGcPaXl9eWWB5KaA+w/81RzZa/18P4izQRHpNBkTGt9HwGfYi9+wut5UgxNq4QSX9A7HIQR6QDvX2Q==}
engines: {node: '>=14.6'}
dependencies:
'@neondatabase/serverless': 0.7.2
bufferutil: 4.0.8
utf-8-validate: 6.0.3
ws: 8.14.2(bufferutil@4.0.8)(utf-8-validate@6.0.3)
dev: false
/@vue/compiler-core@3.4.15: /@vue/compiler-core@3.4.15:
resolution: {integrity: sha512-XcJQVOaxTKCnth1vCxEChteGuwG6wqnUHxAm1DO3gCz0+uXKaJNx8/digSz4dLALCy8n2lKq24jSUs8segoqIw==} resolution: {integrity: sha512-XcJQVOaxTKCnth1vCxEChteGuwG6wqnUHxAm1DO3gCz0+uXKaJNx8/digSz4dLALCy8n2lKq24jSUs8segoqIw==}
dependencies: dependencies:
@ -1505,14 +1577,15 @@ packages:
humanize-ms: 1.2.1 humanize-ms: 1.2.1
dev: false dev: false
/ai@2.2.31(react@18.2.0)(solid-js@1.8.11)(svelte@4.2.9)(vue@3.4.15): /ai@3.0.12(react@18.2.0)(solid-js@1.8.11)(svelte@4.2.9)(vue@3.4.15)(zod@3.22.4):
resolution: {integrity: sha512-WQH13RxP+RYo9IE/FX8foNQh9gcKO/dhl9OGy5JL2bHJVBlnugPmH2CYJWaRt+mvjXHaU8txB+jzGo/fbtH2HA==} resolution: {integrity: sha512-cP/Moag7PcDOE3kA7WU00YS+mQiuPpAxY+uf57lkWwnqSB1K3/RzwnRF+LD1FqgJfCubI4WEbajMPbnnCr8lAg==}
engines: {node: '>=14.6'} engines: {node: '>=14.6'}
peerDependencies: peerDependencies:
react: ^18.2.0 react: ^18.2.0
solid-js: ^1.7.7 solid-js: ^1.7.7
svelte: ^3.0.0 || ^4.0.0 svelte: ^3.0.0 || ^4.0.0
vue: ^3.3.4 vue: ^3.3.4
zod: ^3.0.0
peerDependenciesMeta: peerDependenciesMeta:
react: react:
optional: true optional: true
@ -1522,8 +1595,11 @@ packages:
optional: true optional: true
vue: vue:
optional: true optional: true
zod:
optional: true
dependencies: dependencies:
eventsource-parser: 1.0.0 eventsource-parser: 1.0.0
jsondiffpatch: 0.6.0
nanoid: 3.3.6 nanoid: 3.3.6
react: 18.2.0 react: 18.2.0
solid-js: 1.8.11 solid-js: 1.8.11
@ -1534,6 +1610,8 @@ packages:
swr-store: 0.10.6 swr-store: 0.10.6
swrv: 1.0.4(vue@3.4.15) swrv: 1.0.4(vue@3.4.15)
vue: 3.4.15(typescript@5.3.3) vue: 3.4.15(typescript@5.3.3)
zod: 3.22.4
zod-to-json-schema: 3.22.4(zod@3.22.4)
dev: false dev: false
/ajv@6.12.6: /ajv@6.12.6:
@ -1781,6 +1859,14 @@ packages:
update-browserslist-db: 1.0.13(browserslist@4.22.2) update-browserslist-db: 1.0.13(browserslist@4.22.2)
dev: true dev: true
/bufferutil@4.0.8:
resolution: {integrity: sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==}
engines: {node: '>=6.14.2'}
requiresBuild: true
dependencies:
node-gyp-build: 4.8.0
dev: false
/busboy@1.6.0: /busboy@1.6.0:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'} engines: {node: '>=10.16.0'}
@ -1825,6 +1911,11 @@ packages:
supports-color: 7.2.0 supports-color: 7.2.0
dev: true dev: true
/chalk@5.3.0:
resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
dev: false
/character-entities-legacy@1.1.4: /character-entities-legacy@1.1.4:
resolution: {integrity: sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==} resolution: {integrity: sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==}
dev: false dev: false
@ -1989,10 +2080,63 @@ packages:
/csstype@3.1.3: /csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
/d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
dependencies:
internmap: 2.0.3
dev: false
/d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
dev: false
/d3-format@3.1.0:
resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==}
engines: {node: '>=12'}
dev: false
/d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
dependencies:
d3-color: 3.1.0
dev: false
/d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
dependencies:
d3-array: 3.2.4
d3-format: 3.1.0
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
dev: false
/d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
dependencies:
d3-time: 3.1.0
dev: false
/d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
dependencies:
d3-array: 3.2.4
dev: false
/damerau-levenshtein@1.0.8: /damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
dev: true dev: true
/date-fns@3.3.1:
resolution: {integrity: sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==}
dev: false
/debug@3.2.7: /debug@3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies: peerDependencies:
@ -2060,6 +2204,10 @@ packages:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
dev: true dev: true
/diff-match-patch@1.0.5:
resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==}
dev: false
/diff@5.1.0: /diff@5.1.0:
resolution: {integrity: sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==} resolution: {integrity: sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==}
engines: {node: '>=0.3.1'} engines: {node: '>=0.3.1'}
@ -2742,12 +2890,12 @@ packages:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
dev: true dev: true
/geist@1.2.1(next@14.1.0): /geist@1.2.1(next@14.2.0-canary.6):
resolution: {integrity: sha512-xCl7zWfnWqc+TbCG5qqyeT5tnVlOO4pSJsT3Ei59DN1SR4N2VlauF8Fv0D1pPFXGUJgu6RMoeZX+wsR4T9bMhg==} resolution: {integrity: sha512-xCl7zWfnWqc+TbCG5qqyeT5tnVlOO4pSJsT3Ei59DN1SR4N2VlauF8Fv0D1pPFXGUJgu6RMoeZX+wsR4T9bMhg==}
peerDependencies: peerDependencies:
next: ^13.2 || ^14 next: ^13.2 || ^14
dependencies: dependencies:
next: 14.1.0(react-dom@18.2.0)(react@18.2.0) next: 14.2.0-canary.6(react-dom@18.2.0)(react@18.2.0)
dev: false dev: false
/get-intrinsic@1.2.2: /get-intrinsic@1.2.2:
@ -2841,14 +2989,6 @@ packages:
slash: 3.0.0 slash: 3.0.0
dev: true dev: true
/goober@2.1.14(csstype@3.1.3):
resolution: {integrity: sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==}
peerDependencies:
csstype: ^3.0.10
dependencies:
csstype: 3.1.3
dev: false
/gopd@1.0.1: /gopd@1.0.1:
resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==}
dependencies: dependencies:
@ -2976,6 +3116,11 @@ packages:
side-channel: 1.0.4 side-channel: 1.0.4
dev: true dev: true
/internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
dev: false
/invariant@2.2.4: /invariant@2.2.4:
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
dependencies: dependencies:
@ -3252,6 +3397,16 @@ packages:
minimist: 1.2.8 minimist: 1.2.8
dev: true dev: true
/jsondiffpatch@0.6.0:
resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
dependencies:
'@types/diff-match-patch': 1.0.36
chalk: 5.3.0
diff-match-patch: 1.0.5
dev: false
/jsx-ast-utils@3.3.5: /jsx-ast-utils@3.3.5:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
@ -3335,6 +3490,10 @@ packages:
resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==} resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==}
dev: true dev: true
/lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
dev: false
/lodash.isplainobject@4.0.6: /lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
dev: true dev: true
@ -3880,7 +4039,7 @@ packages:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
dev: true dev: true
/next-auth@5.0.0-beta.4(next@14.1.0)(react@18.2.0): /next-auth@5.0.0-beta.4(next@14.2.0-canary.6)(react@18.2.0):
resolution: {integrity: sha512-vgocjvwPA8gxd/zrIP/vr9lJ/HeNe+C56lPP1D3sdyenHt8KncQV6ro7q0xCsDp1fcOKx7WAWVZH5o8aMxDzgw==} resolution: {integrity: sha512-vgocjvwPA8gxd/zrIP/vr9lJ/HeNe+C56lPP1D3sdyenHt8KncQV6ro7q0xCsDp1fcOKx7WAWVZH5o8aMxDzgw==}
peerDependencies: peerDependencies:
next: ^14 next: ^14
@ -3891,24 +4050,24 @@ packages:
optional: true optional: true
dependencies: dependencies:
'@auth/core': 0.18.4 '@auth/core': 0.18.4
next: 14.1.0(react-dom@18.2.0)(react@18.2.0) next: 14.2.0-canary.6(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0 react: 18.2.0
dev: false dev: false
/next-themes@0.2.1(next@14.1.0)(react-dom@18.2.0)(react@18.2.0): /next-themes@0.2.1(next@14.2.0-canary.6)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A==} resolution: {integrity: sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A==}
peerDependencies: peerDependencies:
next: '*' next: '*'
react: '*' react: '*'
react-dom: '*' react-dom: '*'
dependencies: dependencies:
next: 14.1.0(react-dom@18.2.0)(react@18.2.0) next: 14.2.0-canary.6(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0 react: 18.2.0
react-dom: 18.2.0(react@18.2.0) react-dom: 18.2.0(react@18.2.0)
dev: false dev: false
/next@14.1.0(react-dom@18.2.0)(react@18.2.0): /next@14.2.0-canary.6(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-wlzrsbfeSU48YQBjZhDzOwhWhGsy+uQycR8bHAOt1LY1bn3zZEcDyHQOEoN3aWzQ8LHCAJ1nqrWCc9XF2+O45Q==} resolution: {integrity: sha512-t7wmy7fdAp0aH00u9MEzmF+GPMS8TGZrP/9QTpzpy7C8i5NT45KY4ZtcCQUXEeAyf13azuGiP2swET6eTqlOhA==}
engines: {node: '>=18.17.0'} engines: {node: '>=18.17.0'}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@ -3922,8 +4081,8 @@ packages:
sass: sass:
optional: true optional: true
dependencies: dependencies:
'@next/env': 14.1.0 '@next/env': 14.2.0-canary.6
'@swc/helpers': 0.5.2 '@swc/helpers': 0.5.5
busboy: 1.6.0 busboy: 1.6.0
caniuse-lite: 1.0.30001579 caniuse-lite: 1.0.30001579
graceful-fs: 4.2.11 graceful-fs: 4.2.11
@ -3932,15 +4091,15 @@ packages:
react-dom: 18.2.0(react@18.2.0) react-dom: 18.2.0(react@18.2.0)
styled-jsx: 5.1.1(react@18.2.0) styled-jsx: 5.1.1(react@18.2.0)
optionalDependencies: optionalDependencies:
'@next/swc-darwin-arm64': 14.1.0 '@next/swc-darwin-arm64': 14.2.0-canary.6
'@next/swc-darwin-x64': 14.1.0 '@next/swc-darwin-x64': 14.2.0-canary.6
'@next/swc-linux-arm64-gnu': 14.1.0 '@next/swc-linux-arm64-gnu': 14.2.0-canary.6
'@next/swc-linux-arm64-musl': 14.1.0 '@next/swc-linux-arm64-musl': 14.2.0-canary.6
'@next/swc-linux-x64-gnu': 14.1.0 '@next/swc-linux-x64-gnu': 14.2.0-canary.6
'@next/swc-linux-x64-musl': 14.1.0 '@next/swc-linux-x64-musl': 14.2.0-canary.6
'@next/swc-win32-arm64-msvc': 14.1.0 '@next/swc-win32-arm64-msvc': 14.2.0-canary.6
'@next/swc-win32-ia32-msvc': 14.1.0 '@next/swc-win32-ia32-msvc': 14.2.0-canary.6
'@next/swc-win32-x64-msvc': 14.1.0 '@next/swc-win32-x64-msvc': 14.2.0-canary.6
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'
- babel-plugin-macros - babel-plugin-macros
@ -3963,6 +4122,11 @@ packages:
whatwg-url: 5.0.0 whatwg-url: 5.0.0
dev: false dev: false
/node-gyp-build@4.8.0:
resolution: {integrity: sha512-u6fs2AEUljNho3EYTJNBfImO5QTo/J/1Etd+NVdCj7qWKUSN/bSLkZwhDv7I+w/MSC6qJ4cknepkAYykDdK8og==}
hasBin: true
dev: false
/node-releases@2.0.14: /node-releases@2.0.14:
resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==}
dev: true dev: true
@ -4170,6 +4334,26 @@ packages:
is-reference: 3.0.2 is-reference: 3.0.2
dev: false dev: false
/pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
dev: false
/pg-protocol@1.6.0:
resolution: {integrity: sha512-M+PDm637OY5WM307051+bsDia5Xej6d9IR4GwJse1qA1DIhiKlksvrneZOYQq42OM+spubpcNYEo2FcKQrDk+Q==}
dev: false
/pg-types@2.2.0:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
dependencies:
pg-int8: 1.0.1
postgres-array: 2.0.0
postgres-bytea: 1.0.0
postgres-date: 1.0.7
postgres-interval: 1.2.0
dev: false
/picocolors@1.0.0: /picocolors@1.0.0:
resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
@ -4273,6 +4457,28 @@ packages:
picocolors: 1.0.0 picocolors: 1.0.0
source-map-js: 1.0.2 source-map-js: 1.0.2
/postgres-array@2.0.0:
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
engines: {node: '>=4'}
dev: false
/postgres-bytea@1.0.0:
resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==}
engines: {node: '>=0.10.0'}
dev: false
/postgres-date@1.0.7:
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
engines: {node: '>=0.10.0'}
dev: false
/postgres-interval@1.2.0:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
dependencies:
xtend: 4.0.2
dev: false
/preact-render-to-string@5.2.3(preact@10.11.3): /preact-render-to-string@5.2.3(preact@10.11.3):
resolution: {integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==} resolution: {integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==}
peerDependencies: peerDependencies:
@ -4347,20 +4553,6 @@ packages:
scheduler: 0.23.0 scheduler: 0.23.0
dev: false dev: false
/react-hot-toast@2.4.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==}
engines: {node: '>=10'}
peerDependencies:
react: '>=16'
react-dom: '>=16'
dependencies:
goober: 2.1.14(csstype@3.1.3)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
transitivePeerDependencies:
- csstype
dev: false
/react-intersection-observer@9.5.3(react@18.2.0): /react-intersection-observer@9.5.3(react@18.2.0):
resolution: {integrity: sha512-NJzagSdUPS5rPhaLsHXYeJbsvdpbJwL6yCHtMk91hc0ufQ2BnXis+0QQ9NBh6n9n+Q3OyjR6OQLShYbaNBkThQ==} resolution: {integrity: sha512-NJzagSdUPS5rPhaLsHXYeJbsvdpbJwL6yCHtMk91hc0ufQ2BnXis+0QQ9NBh6n9n+Q3OyjR6OQLShYbaNBkThQ==}
peerDependencies: peerDependencies:
@ -4766,6 +4958,16 @@ packages:
swr-store: 0.10.6 swr-store: 0.10.6
dev: false dev: false
/sonner@1.4.3(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-SArYlHbkjqRuLiR0iGY2ZSr09oOrxw081ZZkQPfXrs8aZQLIBOLOdzTYxGJB5yIZ7qL56UEPmrX1YqbODwG0Lw==}
peerDependencies:
react: ^18.0.0
react-dom: ^18.0.0
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/source-map-js@1.0.2: /source-map-js@1.0.2:
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -5313,6 +5515,24 @@ packages:
react: 18.2.0 react: 18.2.0
dev: false dev: false
/usehooks-ts@2.16.0(react@18.2.0):
resolution: {integrity: sha512-bez95WqYujxp6hFdM/CpRDiVPirZPxlMzOH2QB8yopoKQMXpscyZoxOjpEdaxvV+CAWUDSM62cWnqHE0E/MZ7w==}
engines: {node: '>=16.15.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18
dependencies:
lodash.debounce: 4.0.8
react: 18.2.0
dev: false
/utf-8-validate@6.0.3:
resolution: {integrity: sha512-uIuGf9TWQ/y+0Lp+KGZCMuJWc3N9BHA+l/UmHd/oUHwJJDeysyTRxNQVkbzsIWfGFbRe3OcgML/i0mvVRPOyDA==}
engines: {node: '>=6.14.2'}
requiresBuild: true
dependencies:
node-gyp-build: 4.8.0
dev: false
/util-deprecate@1.0.2: /util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
dev: true dev: true
@ -5459,6 +5679,22 @@ packages:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
dev: true dev: true
/ws@8.14.2(bufferutil@4.0.8)(utf-8-validate@6.0.3):
resolution: {integrity: sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
dependencies:
bufferutil: 4.0.8
utf-8-validate: 6.0.3
dev: false
/xtend@4.0.2: /xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'} engines: {node: '>=0.4'}
@ -5482,6 +5718,18 @@ packages:
resolution: {integrity: sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==} resolution: {integrity: sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==}
dev: false dev: false
/zod-to-json-schema@3.22.4(zod@3.22.4):
resolution: {integrity: sha512-2Ed5dJ+n/O3cU383xSY28cuVi0BCQhF8nYqWU5paEpl7fVdqdAmiLdqLyfblbNdfOFwFfi/mqU4O1pwc60iBhQ==}
peerDependencies:
zod: ^3.22.4
dependencies:
zod: 3.22.4
dev: false
/zod@3.22.4:
resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==}
dev: false
/zwitch@2.0.4: /zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
dev: false dev: false

34
scripts/seed.mjs Normal file
View file

@ -0,0 +1,34 @@
import { db } from '@vercel/postgres'
async function seedUsers(client) {
try {
await client.sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`
const createTable = await client.sql`
CREATE TABLE IF NOT EXISTS users (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL
salt TEXT NOT NULL
);
`
console.log(`Created "users" table`)
return {
createTable,
}
} catch (error) {
console.error('Error seeding users:', error)
throw error
}
}
async function main() {
const client = await db.connect()
await seedUsers(client)
await client.end()
}
main().catch(err => {
console.error('An error occurred while attempting to seed the database:', err)
})

View file

@ -1,7 +1,13 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
darkMode: ['class'], darkMode: ['class'],
content: ['app/**/*.{ts,tsx}', 'components/**/*.{ts,tsx}'], content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}'
],
prefix: '',
theme: { theme: {
container: { container: {
center: true, center: true,
@ -51,41 +57,21 @@ module.exports = {
} }
}, },
borderRadius: { borderRadius: {
lg: `var(--radius)`, lg: 'var(--radius)',
md: `calc(var(--radius) - 2px)`, md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)' sm: 'calc(var(--radius) - 4px)'
}, },
keyframes: { keyframes: {
'accordion-down': { 'accordion-down': {
from: { height: 0 }, from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' } to: { height: 'var(--radix-accordion-content-height)' }
}, },
'accordion-up': { 'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' }, from: { height: 'var(--radix-accordion-content-height)' },
to: { height: 0 } to: { height: '0' }
},
'slide-from-left': {
'0%': {
transform: 'translateX(-100%)'
},
'100%': {
transform: 'translateX(0)'
}
},
'slide-to-left': {
'0%': {
transform: 'translateX(0)'
},
'100%': {
transform: 'translateX(-100%)'
}
} }
}, },
animation: { animation: {
'slide-from-left':
'slide-from-left 0.3s cubic-bezier(0.82, 0.085, 0.395, 0.895)',
'slide-to-left':
'slide-to-left 0.25s cubic-bezier(0.82, 0.085, 0.395, 0.895)',
'accordion-down': 'accordion-down 0.2s ease-out', 'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out' 'accordion-up': 'accordion-up 0.2s ease-out'
} }