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`
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
KV_URL=XXXXXXXX
@ -18,3 +11,12 @@ KV_REST_API_URL=XXXXXXXX
KV_REST_API_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)
- [Radix UI](https://radix-ui.com) for headless component primitives
- 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
## 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:
[![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
@ -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.
## 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
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
pnpm install
pnpm seed
pnpm dev
```

View file

@ -2,8 +2,10 @@ import { type Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import { auth } from '@/auth'
import { getChat } from '@/app/actions'
import { getChat, getMissingKeys } from '@/app/actions'
import { Chat } from '@/components/chat'
import { AI } from '@/lib/chat/actions'
import { Session } from '@/lib/types'
export interface ChatPageProps {
params: {
@ -27,21 +29,32 @@ export async function generateMetadata({
}
export default async function ChatPage({ params }: ChatPageProps) {
const session = await auth()
const session = (await auth()) as Session
const missingKeys = await getMissingKeys()
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) {
notFound()
redirect('/')
}
if (chat?.userId !== session?.user?.id) {
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 { 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() {
const id = nanoid()
return <Chat id={id} />
export const metadata = {
title: 'Next.js AI Chatbot'
}
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
}
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%;
--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-foreground: 240 10% 3.9%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
@ -25,13 +19,18 @@
--secondary: 240 4.8% 95.9%;
--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-foreground: ;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--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;
}
@ -40,17 +39,11 @@
--background: 240 10% 3.9%;
--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-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
@ -58,13 +51,18 @@
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: ;
--accent-foreground: 0 0% 98%;
--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 { GeistMono } from 'geist/font/mono'
@ -7,6 +6,7 @@ import { cn } from '@/lib/utils'
import { TailwindIndicator } from '@/components/tailwind-indicator'
import { Providers } from '@/components/providers'
import { Header } from '@/components/header'
import { Toaster } from '@/components/ui/sonner'
export const metadata = {
metadataBase: new URL(`https://${process.env.VERCEL_URL}`),
@ -43,7 +43,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
GeistMono.variable
)}
>
<Toaster />
<Toaster position="top-center" />
<Providers
attribute="class"
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 { notFound } from 'next/navigation'
import { notFound, redirect } from 'next/navigation'
import { formatDate } from '@/lib/utils'
import { getSharedChat } from '@/app/actions'
import { ChatList } from '@/components/chat-list'
import { FooterText } from '@/components/footer'
import { AI, UIState, getUIStateFromAIState } from '@/lib/chat/actions'
export const runtime = 'edge'
export const preferredRegion = 'home'
interface SharePageProps {
params: {
@ -29,11 +33,13 @@ export default async function SharePage({ params }: SharePageProps) {
notFound()
}
const uiState: UIState = getUIStateFromAIState(chat)
return (
<>
<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="max-w-2xl mx-auto md:px-6">
<div className="border-b bg-background px-4 py-6 md:px-6 md:py-8">
<div className="mx-auto max-w-2xl">
<div className="space-y-1 md:-mx-8">
<h1 className="text-2xl font-bold">{chat.title}</h1>
<div className="text-sm text-muted-foreground">
@ -42,7 +48,9 @@ export default async function SharePage({ params }: SharePageProps) {
</div>
</div>
</div>
<ChatList messages={chat.messages} />
<AI>
<ChatList messages={uiState} />
</AI>
</div>
<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

89
auth.ts
View file

@ -1,39 +1,62 @@
import NextAuth, { type DefaultSession } from 'next-auth'
import GitHub from 'next-auth/providers/github'
import NextAuth from 'next-auth'
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 Session {
user: {
/** The user's id. */
id: string
} & DefaultSession['user']
interface User {
id: string
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 {
handlers: { GET, POST },
auth
} = NextAuth({
providers: [GitHub],
callbacks: {
jwt({ token, profile }) {
if (profile) {
token.id = profile.id
token.image = profile.avatar_url || profile.picture
export const { auth, signIn, signOut } = NextAuth({
...authConfig,
providers: [
Credentials({
async authorize(credentials) {
const parsedCredentials = z
.object({
email: z.string().email(),
password: z.string().min(6)
})
.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 null
}
return token
},
session: ({ session, token }) => {
if (session?.user && token?.id) {
session.user.id = String(token.id)
}
return session
},
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) {
return (
<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
href="/"
className={cn(

View file

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

View file

@ -1,102 +1,124 @@
import * as React from 'react'
import { type UseChatHelpers } from 'ai/react'
import { shareChat } from '@/app/actions'
import { Button } from '@/components/ui/button'
import { PromptForm } from '@/components/prompt-form'
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 { 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
extends Pick<
UseChatHelpers,
| 'append'
| 'isLoading'
| 'reload'
| 'messages'
| 'stop'
| 'input'
| 'setInput'
> {
export interface ChatPanelProps {
id?: string
title?: string
input: string
setInput: (value: string) => void
}
export function ChatPanel({
id,
title,
isLoading,
stop,
append,
reload,
input,
setInput,
messages
}: ChatPanelProps) {
export function ChatPanel({ id, title, input, setInput }: ChatPanelProps) {
const [aiState] = useAIState()
const [messages, setMessages] = useUIState<typeof AI>()
const { submitUserMessage } = useActions()
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 (
<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 />
<div className="mx-auto sm:max-w-2xl sm:px-4">
<div className="flex items-center justify-center h-12">
{isLoading ? (
<Button
variant="outline"
onClick={() => stop()}
className="bg-background"
>
<IconStop className="mr-2" />
Stop generating
</Button>
) : (
messages?.length >= 2 && (
<div className="flex space-x-2">
<Button variant="outline" onClick={() => reload()}>
<IconRefresh className="mr-2" />
Regenerate response
</Button>
{id && title ? (
<>
<Button
variant="outline"
onClick={() => setShareDialogOpen(true)}
>
<IconShare className="mr-2" />
Share
</Button>
<ChatShareDialog
open={shareDialogOpen}
onOpenChange={setShareDialogOpen}
onCopy={() => setShareDialogOpen(false)}
shareChat={shareChat}
chat={{
id,
title,
messages
}}
/>
</>
) : null}
<div className="mb-4 grid grid-cols-2 gap-2 px-4 sm:px-0">
{messages.length === 0 &&
exampleMessages.map((example, index) => (
<div
key={example.heading}
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
])
}}
>
<div className="text-sm font-semibold">{example.heading}</div>
<div className="text-sm text-zinc-600">
{example.subheading}
</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">
<PromptForm
onSubmit={async value => {
await append({
id,
content: value,
role: 'user'
})
}}
input={input}
setInput={setInput}
isLoading={isLoading}
/>
{messages?.length >= 2 ? (
<div className="flex h-12 items-center justify-center">
<div className="flex space-x-2">
{id && title ? (
<>
<Button
variant="outline"
onClick={() => setShareDialogOpen(true)}
>
<IconShare className="mr-2" />
Share
</Button>
<ChatShareDialog
open={shareDialogOpen}
onOpenChange={setShareDialogOpen}
onCopy={() => setShareDialogOpen(false)}
shareChat={shareChat}
chat={{
id,
title,
messages: aiState.messages
}}
/>
</>
) : null}
</div>
</div>
) : null}
<div className="space-y-4 border-t bg-background px-4 py-2 shadow-lg sm:rounded-t-xl sm:border md:py-4">
<PromptForm input={input} setInput={setInput} />
<FooterText className="hidden sm:block" />
</div>
</div>

View file

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

View file

@ -2,7 +2,7 @@
import * as React from 'react'
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 { Button } from '@/components/ui/button'
@ -42,18 +42,7 @@ export function ChatShareDialog({
url.pathname = chat.sharePath
copyToClipboard(url.toString())
onCopy()
toast.success('Share link copied to clipboard', {
style: {
borderRadius: '10px',
background: '#333',
color: '#fff',
fontSize: '14px'
},
iconTheme: {
primary: 'white',
secondary: 'black'
}
})
toast.success('Share link copied to clipboard')
},
[copyToClipboard, onCopy]
)

View file

@ -1,61 +1,60 @@
'use client'
import { useChat, type Message } from 'ai/react'
import { cn } from '@/lib/utils'
import { ChatList } from '@/components/chat-list'
import { ChatPanel } from '@/components/chat-panel'
import { EmptyScreen } from '@/components/empty-screen'
import { ChatScrollAnchor } from '@/components/chat-scroll-anchor'
import { useLocalStorage } from '@/lib/hooks/use-local-storage'
import {
Dialog,
DialogContent,
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 { useEffect, useState } from 'react'
import { useUIState, useAIState } from 'ai/rsc'
import { Session } from '@/lib/types'
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'> {
initialMessages?: Message[]
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 path = usePathname()
const [previewToken, setPreviewToken] = useLocalStorage<string | null>(
'ai-token',
null
)
const [previewTokenDialog, setPreviewTokenDialog] = useState(IS_PREVIEW)
const [previewTokenInput, setPreviewTokenInput] = useState(previewToken ?? '')
const { messages, append, reload, stop, isLoading, input, setInput } =
useChat({
initialMessages,
id,
body: {
id,
previewToken
},
onResponse(response) {
if (response.status === 401) {
toast.error(response.statusText)
}
},
onFinish() {
if (!path.includes('chat')) {
window.history.pushState({}, '', `/chat/${id}`)
}
const [input, setInput] = useState('')
const [messages] = useUIState()
const [aiState] = useAIState()
const isLoading = true
const [_, setNewChatId] = useLocalStorage('newChatId', id)
useEffect(() => {
if (session?.user) {
if (!path.includes('chat') && messages.length === 1) {
window.history.replaceState({}, '', `/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 (
<>
<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} />
)}
</div>
<ChatPanel
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>
<ChatPanel id={id} input={input} setInput={setInput} />
</>
)
}

View file

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

View file

@ -22,34 +22,31 @@ const exampleMessages = [
export function EmptyScreen({ setInput }: Pick<UseChatHelpers, 'setInput'>) {
return (
<div className="mx-auto max-w-2xl px-4">
<div className="rounded-lg border bg-background p-8">
<h1 className="mb-2 text-lg font-semibold">
<div className="flex flex-col gap-2 rounded-lg border bg-background p-8">
<h1 className="text-lg font-semibold">
Welcome to Next.js AI Chatbot!
</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{' '}
<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">
Vercel KV
</ExternalLink>
.
</p>
<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>
<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>
)

View file

@ -14,9 +14,10 @@ import { UserMenu } from '@/components/user-menu'
import { SidebarMobile } from './sidebar-mobile'
import { SidebarToggle } from './sidebar-toggle'
import { ChatHistory } from './chat-history'
import { Session } from '@/lib/types'
async function UserOrLogin() {
const session = await auth()
const session = (await auth()) as Session
return (
<>
{session?.user ? (
@ -27,7 +28,7 @@ async function UserOrLogin() {
<SidebarToggle />
</>
) : (
<Link href="/" target="_blank" rel="nofollow">
<Link href="/" rel="nofollow">
<IconNextChat className="size-6 mr-2 dark:hidden" inverted />
<IconNextChat className="hidden size-6 mr-2 dark:block" />
</Link>
@ -38,7 +39,7 @@ async function UserOrLogin() {
<UserMenu user={session.user} />
) : (
<Button variant="link" asChild className="-ml-2">
<Link href="/sign-in?callbackUrl=/">Login</Link>
<Link href="/login">Login</Link>
</Button>
)}
</div>
@ -65,7 +66,7 @@ export function Header() {
<span className="hidden ml-2 md:flex">GitHub</span>
</a>
<a
href="https://github.com/vercel/nextjs-ai-chatbot/"
href="https://vercel.com/templates/Next.js/nextjs-ai-chatbot"
target="_blank"
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 Textarea from 'react-textarea-autosize'
import { UseChatHelpers } from 'ai/react'
import { useEnterSubmit } from '@/lib/hooks/use-enter-submit'
import { cn } from '@/lib/utils'
import { Button, buttonVariants } from '@/components/ui/button'
import { useActions, useUIState } from 'ai/rsc'
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 {
Tooltip,
TooltipContent,
TooltipTrigger
} 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'
export interface PromptProps
extends Pick<UseChatHelpers, 'input' | 'setInput'> {
onSubmit: (value: string) => void
isLoading: boolean
}
export function PromptForm({
onSubmit,
input,
setInput,
isLoading
}: PromptProps) {
setInput
}: {
input: string
setInput: (value: string) => void
}) {
const router = useRouter()
const { formRef, onKeyDown } = useEnterSubmit()
const inputRef = React.useRef<HTMLTextAreaElement>(null)
const router = useRouter()
const { submitUserMessage } = useActions()
const [_, setMessages] = useUIState<typeof AI>()
React.useEffect(() => {
if (inputRef.current) {
inputRef.current.focus()
@ -35,33 +39,47 @@ export function PromptForm({
return (
<form
onSubmit={async e => {
e.preventDefault()
if (!input?.trim()) {
return
}
setInput('')
await onSubmit(input)
}}
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>
<TooltipTrigger asChild>
<button
onClick={e => {
e.preventDefault()
router.refresh()
<Button
variant="outline"
size="icon"
className="absolute left-0 top-4 size-8 rounded-full bg-background p-0 sm:left-4"
onClick={() => {
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 />
<span className="sr-only">New Chat</span>
</button>
</Button>
</TooltipTrigger>
<TooltipContent>New Chat</TooltipContent>
</Tooltip>
@ -69,21 +87,21 @@ export function PromptForm({
ref={inputRef}
tabIndex={0}
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}
value={input}
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">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="submit"
size="icon"
disabled={isLoading || input === ''}
>
<Button type="submit" size="icon" disabled={input === ''}>
<IconArrowElbow />
<span className="sr-only">Send message</span>
</Button>

View file

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

View file

@ -20,7 +20,10 @@ export function SidebarMobile({ children }: SidebarMobileProps) {
<span className="sr-only">Toggle Sidebar</span>
</Button>
</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>
</SheetContent>
</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 AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
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
)}
{...props}
@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
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
)}
{...props}
@ -51,13 +51,13 @@ const AlertDialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
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
)}
{...props}
/>
)
AlertDialogHeader.displayName = 'AlertDialogHeader'
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
@ -65,13 +65,13 @@ const AlertDialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
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
)}
{...props}
/>
)
AlertDialogFooter.displayName = 'AlertDialogFooter'
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
@ -79,7 +79,7 @@ const AlertDialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
@ -91,7 +91,7 @@ const AlertDialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
@ -117,8 +117,8 @@ const AlertDialogCancel = React.forwardRef<
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
@ -137,5 +137,5 @@ export {
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel
AlertDialogCancel,
}

View file

@ -1,25 +1,25 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils"
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
"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: {
variant: {
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:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground'
}
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: 'default'
}
variant: "default",
},
}
)

View file

@ -5,26 +5,26 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
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: {
variant: {
default:
'bg-primary text-primary-foreground shadow-md hover:bg-primary/90',
'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
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:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'shadow-none hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 shadow-none hover:underline'
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-8 px-4 py-2',
sm: 'h-8 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'size-8 p-0'
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'size-9'
}
},
defaultVariants: {

View file

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

View file

@ -1,10 +1,10 @@
'use client'
"use client"
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { Cross2Icon } from "@radix-ui/react-icons"
import { cn } from '@/lib/utils'
import { IconClose } from '@/components/ui/icons'
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
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
)}
{...props}
@ -38,14 +38,14 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
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
)}
{...props}
>
{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">
<IconClose className="size-4" />
<Cross2Icon className="size-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
@ -59,13 +59,13 @@ const DialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
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
)}
{...props}
/>
)
DialogHeader.displayName = 'DialogHeader'
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
@ -73,13 +73,13 @@ const DialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
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
)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
@ -88,7 +88,7 @@ const DialogTitle = React.forwardRef<
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
@ -102,7 +102,7 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
@ -112,11 +112,11 @@ export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription
DialogDescription,
}

View file

@ -1,9 +1,14 @@
'use client'
"use client"
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import * as React from "react"
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
@ -17,6 +22,28 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub
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<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
@ -24,7 +51,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
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
)}
{...props}
@ -42,7 +69,8 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
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
)}
{...props}
@ -60,8 +88,8 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
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',
inset && 'pl-8',
"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",
className
)}
{...props}
@ -69,6 +97,52 @@ const DropdownMenuItem = React.forwardRef<
))
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<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
@ -78,8 +152,8 @@ const DropdownMenuLabel = React.forwardRef<
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
@ -93,7 +167,7 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
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}
/>
))
@ -105,18 +179,20 @@ const DropdownMenuShortcut = ({
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
@ -124,5 +200,6 @@ export {
DropdownMenuPortal,
DropdownMenuSub,
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
extends React.InputHTMLAttributes<HTMLInputElement> {}
@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
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
)}
ref={ref}
@ -20,6 +20,6 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
)
}
)
Input.displayName = 'Input'
Input.displayName = "Input"
export { Input }

View file

@ -1,14 +1,15 @@
'use client'
"use client"
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { cn } from '@/lib/utils'
import * as React from "react"
import {
IconArrowDown,
IconCheck,
IconChevronUpDown
} from '@/components/ui/icons'
CaretSortIcon,
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
} from "@radix-ui/react-icons"
import * as SelectPrimitive from "@radix-ui/react-select"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
@ -23,43 +24,81 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
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
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<IconChevronUpDown className="opacity-50" />
<CaretSortIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
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<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
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',
position === 'popper' && 'translate-y-1',
"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" &&
"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
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
@ -71,7 +110,7 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
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}
/>
))
@ -84,14 +123,14 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
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
)}
{...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>
<IconCheck className="size-4" />
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
@ -105,7 +144,7 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
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}
/>
))
@ -119,5 +158,7 @@ export {
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View file

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

View file

@ -1,11 +1,11 @@
'use client'
"use client"
import * as React from 'react'
import * as SheetPrimitive from '@radix-ui/react-dialog'
import { cva, type VariantProps } from 'class-variance-authority'
import { IconClose } from '@/components/ui/icons'
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { Cross2Icon } from "@radix-ui/react-icons"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
@ -21,7 +21,7 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
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
)}
{...props}
@ -31,21 +31,21 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
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: {
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:
'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',
"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",
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: {
side: 'right'
}
side: "right",
},
}
)
@ -56,7 +56,7 @@ interface SheetContentProps
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = 'left', className, children, ...props }, ref) => (
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
@ -66,7 +66,7 @@ const SheetContent = React.forwardRef<
>
{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">
<IconClose className="size-4" />
<Cross2Icon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
@ -80,13 +80,13 @@ const SheetHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
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
)}
{...props}
/>
)
SheetHeader.displayName = 'SheetHeader'
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
@ -94,13 +94,13 @@ const SheetFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
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
)}
{...props}
/>
)
SheetFooter.displayName = 'SheetFooter'
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
@ -108,7 +108,7 @@ const SheetTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-foreground', className)}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
@ -120,7 +120,7 @@ const SheetDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
@ -136,5 +136,5 @@ export {
SheetHeader,
SheetFooter,
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 SwitchPrimitives from '@radix-ui/react-switch'
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
@ -11,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
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
)}
{...props}
@ -19,7 +19,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
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>

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
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return (
<textarea
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
)}
ref={ref}
@ -19,6 +19,6 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
)
}
)
Textarea.displayName = 'Textarea'
Textarea.displayName = "Textarea"
export { Textarea }

View file

@ -1,9 +1,9 @@
'use client'
"use client"
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
@ -19,7 +19,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
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
)}
{...props}

View file

@ -1,8 +1,4 @@
'use client'
import Image from 'next/image'
import { type Session } from 'next-auth'
import { signOut } from 'next-auth/react'
import { type Session } from '@/lib/types'
import { Button } from '@/components/ui/button'
import {
@ -12,7 +8,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { IconExternalLink } from '@/components/ui/icons'
import { signOut } from '@/auth'
export interface UserMenuProps {
user: Session['user']
@ -29,49 +25,27 @@ export function UserMenu({ user }: UserMenuProps) {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="pl-0">
{user?.image ? (
<Image
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>
)}
<span className="ml-2">{user?.name}</span>
<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">
{getUserInitials(user.email)}
</div>
<span className="ml-2 hidden md:block">{user.email}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={8} align="start" className="w-[180px]">
<DropdownMenuContent sideOffset={8} align="start" className="w-fit">
<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>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<a
href="https://vercel.com"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-between w-full text-xs"
>
Vercel Homepage
<IconExternalLink className="size-3 ml-auto" />
</a>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
signOut({
callbackUrl: '/'
})
}
className="text-xs"
<form
action={async () => {
'use server'
await signOut()
}}
>
Log Out
</DropdownMenuItem>
<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">
Sign Out
</button>
</form>
</DropdownMenuContent>
</DropdownMenu>
</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> {
id: string
@ -16,3 +16,15 @@ export type ServerActionResult<Result> = Promise<
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'
})
}
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 = {
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:fix": "next lint --fix",
"preview": "next build && next start",
"seed": "node -r dotenv/config ./scripts/seed.mjs",
"type-check": "tsc --noEmit",
"format:write": "prettier --write \"{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-dialog": "^1.0.5",
"@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-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
@ -24,29 +26,35 @@
"@vercel/analytics": "^1.1.2",
"@vercel/kv": "^1.0.1",
"@vercel/og": "^0.6.2",
"ai": "^2.2.31",
"@vercel/postgres": "^0.7.2",
"ai": "^3.0.12",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"d3-scale": "^4.0.2",
"date-fns": "^3.3.1",
"focus-trap-react": "^10.2.3",
"framer-motion": "^10.18.0",
"geist": "^1.2.1",
"nanoid": "^5.0.4",
"next": "14.1.0",
"next": "14.2.0-canary.6",
"next-auth": "5.0.0-beta.4",
"next-themes": "^0.2.1",
"openai": "^4.24.7",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-intersection-observer": "^9.5.3",
"react-markdown": "^8.0.7",
"react-syntax-highlighter": "^15.5.0",
"react-textarea-autosize": "^8.5.3",
"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": {
"@tailwindcss/typography": "^0.5.10",
"@types/d3-scale": "^4.0.8",
"@types/node": "^20.11.5",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",

398
pnpm-lock.yaml generated
View file

@ -14,6 +14,9 @@ dependencies:
'@radix-ui/react-dropdown-menu':
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)
'@radix-ui/react-icons':
specifier: ^1.3.0
version: 1.3.0(react@18.2.0)
'@radix-ui/react-label':
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)
@ -41,15 +44,24 @@ dependencies:
'@vercel/og':
specifier: ^0.6.2
version: 0.6.2
'@vercel/postgres':
specifier: ^0.7.2
version: 0.7.2
ai:
specifier: ^2.2.31
version: 2.2.31(react@18.2.0)(solid-js@1.8.11)(svelte@4.2.9)(vue@3.4.15)
specifier: ^3.0.12
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:
specifier: ^0.7.0
version: 0.7.0
clsx:
specifier: ^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:
specifier: ^10.2.3
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)
geist:
specifier: ^1.2.1
version: 1.2.1(next@14.1.0)
version: 1.2.1(next@14.2.0-canary.6)
nanoid:
specifier: ^5.0.4
version: 5.0.4
next:
specifier: 14.1.0
version: 14.1.0(react-dom@18.2.0)(react@18.2.0)
specifier: 14.2.0-canary.6
version: 14.2.0-canary.6(react-dom@18.2.0)(react@18.2.0)
next-auth:
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:
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:
specifier: ^4.24.7
version: 4.24.7
@ -80,9 +92,6 @@ dependencies:
react-dom:
specifier: ^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:
specifier: ^9.5.3
version: 9.5.3(react@18.2.0)
@ -101,11 +110,23 @@ dependencies:
remark-math:
specifier: ^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:
'@tailwindcss/typography':
specifier: ^0.5.10
version: 0.5.10(tailwindcss@3.4.1)
'@types/d3-scale':
specifier: ^4.0.8
version: 4.0.8
'@types/node':
specifier: ^20.11.5
version: 20.11.5
@ -360,8 +381,14 @@ packages:
'@jridgewell/resolve-uri': 3.1.1
'@jridgewell/sourcemap-codec': 1.4.15
/@next/env@14.1.0:
resolution: {integrity: sha512-Py8zIo+02ht82brwwhTg36iogzFqGLPXlRGKQw5s+qP/kMNc4MAyDeEwBKDijk6zTIbegEgu8Qy7C1LboslQAw==}
/@neondatabase/serverless@0.7.2:
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
/@next/eslint-plugin-next@14.1.0:
@ -370,8 +397,8 @@ packages:
glob: 10.3.10
dev: true
/@next/swc-darwin-arm64@14.1.0:
resolution: {integrity: sha512-nUDn7TOGcIeyQni6lZHfzNoo9S0euXnu0jhsbMOmMJUBfgsnESdjN97kM7cBqQxZa8L/bM9om/S5/1dzCrW6wQ==}
/@next/swc-darwin-arm64@14.2.0-canary.6:
resolution: {integrity: sha512-9stpz9B29+/6NF6qdmAGLJR1bG/0WLzaMHTuPy482JHBcuJMVcYKCWzfTkw1v68/Zdkoja404BeaJTV1MRTB3g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
@ -379,8 +406,8 @@ packages:
dev: false
optional: true
/@next/swc-darwin-x64@14.1.0:
resolution: {integrity: sha512-1jgudN5haWxiAl3O1ljUS2GfupPmcftu2RYJqZiMJmmbBT5M1XDffjUtRUzP4W3cBHsrvkfOFdQ71hAreNQP6g==}
/@next/swc-darwin-x64@14.2.0-canary.6:
resolution: {integrity: sha512-NE1paZ1+eIi2hyWZrgigDKyjUoy7YeN+8VCqtcxkHsOoZRir0Ugy9BIidlVxOjlYF72sHuOy082/kl5EpMs7YA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
@ -388,8 +415,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-arm64-gnu@14.1.0:
resolution: {integrity: sha512-RHo7Tcj+jllXUbK7xk2NyIDod3YcCPDZxj1WLIYxd709BQ7WuRYl3OWUNG+WUfqeQBds6kvZYlc42NJJTNi4tQ==}
/@next/swc-linux-arm64-gnu@14.2.0-canary.6:
resolution: {integrity: sha512-1ab2OoRyev9rA8KfkLSz7uc4Qvk/BiM5zXRI3rcrXtdrOBCafZdcSxsqRgcmHYJ5DO1QLf+i5b0q/VAYhzKKQQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@ -397,8 +424,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-arm64-musl@14.1.0:
resolution: {integrity: sha512-v6kP8sHYxjO8RwHmWMJSq7VZP2nYCkRVQ0qolh2l6xroe9QjbgV8siTbduED4u0hlk0+tjS6/Tuy4n5XCp+l6g==}
/@next/swc-linux-arm64-musl@14.2.0-canary.6:
resolution: {integrity: sha512-QAbbtAlBx2Jbl59md7hAkLF3V5eAa22OnHwxxyHOHjpJSVQrrcvQb5u86vfdttxcypSiG0M0xiIE7PYLwPvIXw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@ -406,8 +433,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-x64-gnu@14.1.0:
resolution: {integrity: sha512-zJ2pnoFYB1F4vmEVlb/eSe+VH679zT1VdXlZKX+pE66grOgjmKJHKacf82g/sWE4MQ4Rk2FMBCRnX+l6/TVYzQ==}
/@next/swc-linux-x64-gnu@14.2.0-canary.6:
resolution: {integrity: sha512-O7zWKvvvjEbgzTtBTnQz4iH7ZA7RYxu0v3cUwQd3SnplInBCluaSk0lq5M9bRfHhriVW7/09cyMErY0nq+ZWlw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@ -415,8 +442,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-x64-musl@14.1.0:
resolution: {integrity: sha512-rbaIYFt2X9YZBSbH/CwGAjbBG2/MrACCVu2X0+kSykHzHnYH5FjHxwXLkcoJ10cX0aWCEynpu+rP76x0914atg==}
/@next/swc-linux-x64-musl@14.2.0-canary.6:
resolution: {integrity: sha512-TywXiQUsQVcQKouCJSYfi7CgziH3n+PFC7NgakwKwvvl1VDVmK7TxqkUjNOzfcgF3g/Dg0IIqzLOPqKa1Xp6uw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@ -424,8 +451,8 @@ packages:
dev: false
optional: true
/@next/swc-win32-arm64-msvc@14.1.0:
resolution: {integrity: sha512-o1N5TsYc8f/HpGt39OUQpQ9AKIGApd3QLueu7hXk//2xq5Z9OxmV6sQfNp8C7qYmiOlHYODOGqNNa0e9jvchGQ==}
/@next/swc-win32-arm64-msvc@14.2.0-canary.6:
resolution: {integrity: sha512-r1m4Ugc0u00bIiYeYAC2U/TrwYOuZKWFA1I3I5qdnagoe1Bul4OijYnMkPYuhhjB6bHUUQjcUtHV5lkcnTr5uA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
@ -433,8 +460,8 @@ packages:
dev: false
optional: true
/@next/swc-win32-ia32-msvc@14.1.0:
resolution: {integrity: sha512-XXIuB1DBRCFwNO6EEzCTMHT5pauwaSj4SWs7CYnME57eaReAKBXCnkUE80p/pAZcewm7hs+vGvNqDPacEXHVkw==}
/@next/swc-win32-ia32-msvc@14.2.0-canary.6:
resolution: {integrity: sha512-l2+CnXMqgh6umnptBkptWk2bz+3bKJe/T5ojJlUoLweIs4eKJ3V87+CH8z5zkznqKVHZ5hQLC0JieV0OVun72Q==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
@ -442,8 +469,8 @@ packages:
dev: false
optional: true
/@next/swc-win32-x64-msvc@14.1.0:
resolution: {integrity: sha512-9WEbVRRAqJ3YFVqEZIxUqkiO8l1nool1LmNxygr5HWF8AcSYsEpneUDhmjUVJEzO2A04+oPtZdombzzPPkTtgg==}
/@next/swc-win32-x64-msvc@14.2.0-canary.6:
resolution: {integrity: sha512-1dRrinBhP4R85NVmrxzShqNl9STmLe2ePI1LZ8HkG8RY08o570JiNEwTcbsoqCYzKi7db61xox7todeLjIleIg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@ -731,6 +758,14 @@ packages:
react-dom: 18.2.0(react@18.2.0)
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):
resolution: {integrity: sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==}
peerDependencies:
@ -1211,9 +1246,14 @@ packages:
string.prototype.codepointat: 0.2.1
dev: false
/@swc/helpers@0.5.2:
resolution: {integrity: sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==}
/@swc/counter@0.1.3:
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
dev: false
/@swc/helpers@0.5.5:
resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==}
dependencies:
'@swc/counter': 0.1.3
tslib: 2.6.2
dev: false
@ -1229,12 +1269,26 @@ packages:
tailwindcss: 3.4.1
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:
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
dependencies:
'@types/ms': 0.7.34
dev: false
/@types/diff-match-patch@1.0.36:
resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==}
dev: false
/@types/estree@1.0.5:
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
dev: false
@ -1281,6 +1335,14 @@ packages:
dependencies:
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:
resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==}
@ -1405,6 +1467,16 @@ packages:
yoga-wasm-web: 0.3.3
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:
resolution: {integrity: sha512-XcJQVOaxTKCnth1vCxEChteGuwG6wqnUHxAm1DO3gCz0+uXKaJNx8/digSz4dLALCy8n2lKq24jSUs8segoqIw==}
dependencies:
@ -1505,14 +1577,15 @@ packages:
humanize-ms: 1.2.1
dev: false
/ai@2.2.31(react@18.2.0)(solid-js@1.8.11)(svelte@4.2.9)(vue@3.4.15):
resolution: {integrity: sha512-WQH13RxP+RYo9IE/FX8foNQh9gcKO/dhl9OGy5JL2bHJVBlnugPmH2CYJWaRt+mvjXHaU8txB+jzGo/fbtH2HA==}
/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-cP/Moag7PcDOE3kA7WU00YS+mQiuPpAxY+uf57lkWwnqSB1K3/RzwnRF+LD1FqgJfCubI4WEbajMPbnnCr8lAg==}
engines: {node: '>=14.6'}
peerDependencies:
react: ^18.2.0
solid-js: ^1.7.7
svelte: ^3.0.0 || ^4.0.0
vue: ^3.3.4
zod: ^3.0.0
peerDependenciesMeta:
react:
optional: true
@ -1522,8 +1595,11 @@ packages:
optional: true
vue:
optional: true
zod:
optional: true
dependencies:
eventsource-parser: 1.0.0
jsondiffpatch: 0.6.0
nanoid: 3.3.6
react: 18.2.0
solid-js: 1.8.11
@ -1534,6 +1610,8 @@ packages:
swr-store: 0.10.6
swrv: 1.0.4(vue@3.4.15)
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
/ajv@6.12.6:
@ -1781,6 +1859,14 @@ packages:
update-browserslist-db: 1.0.13(browserslist@4.22.2)
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:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'}
@ -1825,6 +1911,11 @@ packages:
supports-color: 7.2.0
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:
resolution: {integrity: sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==}
dev: false
@ -1989,10 +2080,63 @@ packages:
/csstype@3.1.3:
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:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
dev: true
/date-fns@3.3.1:
resolution: {integrity: sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==}
dev: false
/debug@3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
@ -2060,6 +2204,10 @@ packages:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
dev: true
/diff-match-patch@1.0.5:
resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==}
dev: false
/diff@5.1.0:
resolution: {integrity: sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==}
engines: {node: '>=0.3.1'}
@ -2742,12 +2890,12 @@ packages:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
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==}
peerDependencies:
next: ^13.2 || ^14
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
/get-intrinsic@1.2.2:
@ -2841,14 +2989,6 @@ packages:
slash: 3.0.0
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:
resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==}
dependencies:
@ -2976,6 +3116,11 @@ packages:
side-channel: 1.0.4
dev: true
/internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
dev: false
/invariant@2.2.4:
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
dependencies:
@ -3252,6 +3397,16 @@ packages:
minimist: 1.2.8
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:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
@ -3335,6 +3490,10 @@ packages:
resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==}
dev: true
/lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
dev: false
/lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
dev: true
@ -3880,7 +4039,7 @@ packages:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
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==}
peerDependencies:
next: ^14
@ -3891,24 +4050,24 @@ packages:
optional: true
dependencies:
'@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
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==}
peerDependencies:
next: '*'
react: '*'
react-dom: '*'
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-dom: 18.2.0(react@18.2.0)
dev: false
/next@14.1.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-wlzrsbfeSU48YQBjZhDzOwhWhGsy+uQycR8bHAOt1LY1bn3zZEcDyHQOEoN3aWzQ8LHCAJ1nqrWCc9XF2+O45Q==}
/next@14.2.0-canary.6(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-t7wmy7fdAp0aH00u9MEzmF+GPMS8TGZrP/9QTpzpy7C8i5NT45KY4ZtcCQUXEeAyf13azuGiP2swET6eTqlOhA==}
engines: {node: '>=18.17.0'}
hasBin: true
peerDependencies:
@ -3922,8 +4081,8 @@ packages:
sass:
optional: true
dependencies:
'@next/env': 14.1.0
'@swc/helpers': 0.5.2
'@next/env': 14.2.0-canary.6
'@swc/helpers': 0.5.5
busboy: 1.6.0
caniuse-lite: 1.0.30001579
graceful-fs: 4.2.11
@ -3932,15 +4091,15 @@ packages:
react-dom: 18.2.0(react@18.2.0)
styled-jsx: 5.1.1(react@18.2.0)
optionalDependencies:
'@next/swc-darwin-arm64': 14.1.0
'@next/swc-darwin-x64': 14.1.0
'@next/swc-linux-arm64-gnu': 14.1.0
'@next/swc-linux-arm64-musl': 14.1.0
'@next/swc-linux-x64-gnu': 14.1.0
'@next/swc-linux-x64-musl': 14.1.0
'@next/swc-win32-arm64-msvc': 14.1.0
'@next/swc-win32-ia32-msvc': 14.1.0
'@next/swc-win32-x64-msvc': 14.1.0
'@next/swc-darwin-arm64': 14.2.0-canary.6
'@next/swc-darwin-x64': 14.2.0-canary.6
'@next/swc-linux-arm64-gnu': 14.2.0-canary.6
'@next/swc-linux-arm64-musl': 14.2.0-canary.6
'@next/swc-linux-x64-gnu': 14.2.0-canary.6
'@next/swc-linux-x64-musl': 14.2.0-canary.6
'@next/swc-win32-arm64-msvc': 14.2.0-canary.6
'@next/swc-win32-ia32-msvc': 14.2.0-canary.6
'@next/swc-win32-x64-msvc': 14.2.0-canary.6
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
@ -3963,6 +4122,11 @@ packages:
whatwg-url: 5.0.0
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:
resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==}
dev: true
@ -4170,6 +4334,26 @@ packages:
is-reference: 3.0.2
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:
resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
@ -4273,6 +4457,28 @@ packages:
picocolors: 1.0.0
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):
resolution: {integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==}
peerDependencies:
@ -4347,20 +4553,6 @@ packages:
scheduler: 0.23.0
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):
resolution: {integrity: sha512-NJzagSdUPS5rPhaLsHXYeJbsvdpbJwL6yCHtMk91hc0ufQ2BnXis+0QQ9NBh6n9n+Q3OyjR6OQLShYbaNBkThQ==}
peerDependencies:
@ -4766,6 +4958,16 @@ packages:
swr-store: 0.10.6
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:
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
engines: {node: '>=0.10.0'}
@ -5313,6 +5515,24 @@ packages:
react: 18.2.0
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:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
dev: true
@ -5459,6 +5679,22 @@ packages:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
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:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
@ -5482,6 +5718,18 @@ packages:
resolution: {integrity: sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==}
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:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
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} */
module.exports = {
darkMode: ['class'],
content: ['app/**/*.{ts,tsx}', 'components/**/*.{ts,tsx}'],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}'
],
prefix: '',
theme: {
container: {
center: true,
@ -51,41 +57,21 @@ module.exports = {
}
},
borderRadius: {
lg: `var(--radius)`,
md: `calc(var(--radius) - 2px)`,
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
keyframes: {
'accordion-down': {
from: { height: 0 },
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' }
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
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%)'
}
to: { height: '0' }
}
},
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-up': 'accordion-up 0.2s ease-out'
}