fix: misc UI updates (#12)

This commit is contained in:
Jared Palmer 2023-06-13 14:20:23 -04:00 committed by GitHub
commit 320db5daeb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 163 additions and 167 deletions

View file

@ -42,7 +42,7 @@ export const POST = auth(async function POST(req: Request) {
if (req.auth?.user?.email == null) { if (req.auth?.user?.email == null) {
return return
} }
const title = json.messages[0].content.substring(0, 20) const title = json.messages[0].content.substring(0, 100)
const userId = (req as any).auth?.user?.email const userId = (req as any).auth?.user?.email
const id = json.id ?? nanoid() const id = json.id ?? nanoid()
const createdAt = Date.now() const createdAt = Date.now()

View file

@ -4,25 +4,24 @@ import { type Message } from 'ai-connector'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { ChatMessage } from '@/components/chat-message' import { ChatMessage } from '@/components/chat-message'
import { EmptyScreen } from '@/components/empty-screen'
export interface ChatList { export interface ChatList {
messages: Message[] messages: Message[]
} }
export function ChatList({ messages }: ChatList) { export function ChatList({ messages }: ChatList) {
if (!messages.length) {
return null
}
return ( return (
<div className="relative mx-auto max-w-2xl px-4"> <div className="relative mx-auto max-w-2xl px-4">
{messages.length > 0 ? ( {messages.map((message, index) => (
messages.map((message, index) => ( <div key={index}>
<div key={index}> <ChatMessage message={message} />
<ChatMessage message={message} /> {index < messages.length - 1 && <Separator className="my-8" />}
{index < messages.length - 1 && <Separator className="my-8" />} </div>
</div> ))}
))
) : (
<EmptyScreen />
)}
</div> </div>
) )
} }

View file

@ -1,6 +1,4 @@
'use client' import { type UseChatHelpers } from 'ai-connector'
import { UseChatHelpers } from 'ai-connector'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { ExternalLink } from '@/components/external-link' import { ExternalLink } from '@/components/external-link'
@ -11,7 +9,13 @@ import { IconRefresh, IconStop } from '@/components/ui/icons'
export interface ChatPanelProps export interface ChatPanelProps
extends Pick< extends Pick<
UseChatHelpers, UseChatHelpers,
'append' | 'isLoading' | 'reload' | 'messages' | 'stop' | 'append'
| 'isLoading'
| 'reload'
| 'messages'
| 'stop'
| 'input'
| 'setInput'
> { > {
id?: string id?: string
} }
@ -21,6 +25,8 @@ export function ChatPanel({
stop, stop,
append, append,
reload, reload,
input,
setInput,
messages messages
}: ChatPanelProps) { }: ChatPanelProps) {
return ( return (
@ -58,6 +64,8 @@ export function ChatPanel({
role: 'user' role: 'user'
}) })
}} }}
input={input}
setInput={setInput}
isLoading={isLoading} isLoading={isLoading}
/> />
<p className="hidden px-2 text-center text-xs leading-normal text-muted-foreground sm:block"> <p className="hidden px-2 text-center text-xs leading-normal text-muted-foreground sm:block">

View file

@ -2,24 +2,32 @@
import { useChat, type Message } from 'ai-connector' import { useChat, type Message } from 'ai-connector'
import { cn } from '@/lib/utils'
import { ChatList } from '@/components/chat-list' import { ChatList } from '@/components/chat-list'
import { ChatPanel } from '@/components/chat-panel' import { ChatPanel } from '@/components/chat-panel'
import { EmptyScreen } from '@/components/empty-screen'
export interface ChatProps { export interface ChatProps extends React.ComponentProps<'div'> {
// create?: (input: string) => Chat | undefined;
initialMessages?: Message[] initialMessages?: Message[]
id?: string id?: string
} }
export function Chat({ id, initialMessages }: ChatProps) { export function Chat({ id, initialMessages, className }: ChatProps) {
const { messages, append, reload, stop, isLoading } = useChat({ const { messages, append, reload, stop, isLoading, input, setInput } =
initialMessages, useChat({
id initialMessages,
}) id
})
return ( return (
<div className="pb-[200px] pt-4 md:pt-10"> <>
<ChatList messages={messages} /> <div className={cn('pb-[200px] pt-4 md:pt-10', className)}>
{messages.length ? (
<ChatList messages={messages} />
) : (
<EmptyScreen setInput={setInput} />
)}
</div>
<ChatPanel <ChatPanel
id={id} id={id}
isLoading={isLoading} isLoading={isLoading}
@ -27,7 +35,9 @@ export function Chat({ id, initialMessages }: ChatProps) {
append={append} append={append}
reload={reload} reload={reload}
messages={messages} messages={messages}
input={input}
setInput={setInput}
/> />
</div> </>
) )
} }

View file

@ -1,8 +1,8 @@
import { useChatStore } from '@/lib/hooks/use-chat-store'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { ExternalLink } from '@/components/external-link' import { ExternalLink } from '@/components/external-link'
import { IconArrowRight } from '@/components/ui/icons' import { IconArrowRight } from '@/components/ui/icons'
import { UseChatHelpers } from 'ai-connector'
const exampleMessages = [ const exampleMessages = [
{ {
@ -19,37 +19,37 @@ const exampleMessages = [
} }
] ]
export function EmptyScreen({ className }: React.ComponentProps<'div'>) { export function EmptyScreen({ setInput }: Pick<UseChatHelpers, 'setInput'>) {
const { setDefaultMessage } = useChatStore()
return ( return (
<div className={cn('rounded-lg border bg-background p-8', className)}> <div className="mx-auto max-w-2xl px-4">
<h1 className="mb-2 text-lg font-semibold"> <div className="rounded-lg border bg-background p-8">
Welcome to Next.js Chatbot! <h1 className="mb-2 text-lg font-semibold">
</h1> Welcome to Next.js Chatbot!
<p className="mb-2 leading-normal text-muted-foreground"> </h1>
This is an open source AI chatbot app built with{' '} <p className="mb-2 leading-normal text-muted-foreground">
<ExternalLink href="https://nextjs.org">Next.js</ExternalLink> and{' '} This is an open source AI chatbot app built with{' '}
<ExternalLink href="https://vercel.com/storage/kv"> <ExternalLink href="https://nextjs.org">Next.js</ExternalLink> and{' '}
Vercel KV <ExternalLink href="https://vercel.com/storage/kv">
</ExternalLink> Vercel KV
. </ExternalLink>
</p> .
<p className="leading-normal text-muted-foreground"> </p>
You can start a conversation here or try the following examples: <p className="leading-normal text-muted-foreground">
</p> You can start a conversation here or try the following examples:
<div className="mt-4 flex flex-col items-start space-y-2"> </p>
{exampleMessages.map((message, index) => ( <div className="mt-4 flex flex-col items-start space-y-2">
<Button {exampleMessages.map((message, index) => (
key={index} <Button
variant="link" key={index}
className="h-auto p-0 text-base" variant="link"
onClick={() => setDefaultMessage(message.message)} className="h-auto p-0 text-base"
> onClick={() => setInput(message.message)}
<IconArrowRight className="mr-2 text-muted-foreground" /> >
{message.heading} <IconArrowRight className="mr-2 text-muted-foreground" />
</Button> {message.heading}
))} </Button>
))}
</div>
</div> </div>
</div> </div>
) )

View file

@ -21,7 +21,7 @@ export async function Header() {
<SidebarList session={session} /> <SidebarList session={session} />
</Suspense> </Suspense>
</Sidebar> </Sidebar>
<div className="hidden items-center md:flex"> <div className="flex items-center">
<IconSeparator className="h-6 w-6 text-muted-foreground/50" /> <IconSeparator className="h-6 w-6 text-muted-foreground/50" />
<UserMenu session={session} /> <UserMenu session={session} />
</div> </div>
@ -33,15 +33,15 @@ export async function Header() {
rel="noopener noreferrer" rel="noopener noreferrer"
className={cn(buttonVariants({ variant: 'outline' }))} className={cn(buttonVariants({ variant: 'outline' }))}
> >
<IconGitHub className="mr-2 h-4 w-4" /> <IconGitHub />
<span>GitHub</span> <span className="hidden md:flex ml-2">GitHub</span>
</a> </a>
<a <a
href="https://github.com/vercel/nextjs-ai-chatbot/" href="https://github.com/vercel/nextjs-ai-chatbot/"
target="_blank" target="_blank"
className={cn(buttonVariants())} className={cn(buttonVariants())}
> >
<IconVercel className="mr-2 h-4 w-4" /> <IconVercel className="mr-2" />
<span className="hidden sm:block">Deploy to Vercel</span> <span className="hidden sm:block">Deploy to Vercel</span>
<span className="sm:hidden">Deploy</span> <span className="sm:hidden">Deploy</span>
</a> </a>

View file

@ -0,0 +1,40 @@
'use client'
import * as React from 'react'
import { signIn } from '@auth/nextjs/client'
import { cn } from '@/lib/utils'
import { Button, type ButtonProps } from '@/components/ui/button'
import { IconGitHub, IconSpinner } from '@/components/ui/icons'
interface LoginButtonProps extends ButtonProps {
text?: string
}
export function LoginButton({
className,
text = 'Login with GitHub',
...props
}: LoginButtonProps) {
const [isLoading, setIsLoading] = React.useState(false)
return (
<Button
variant="outline"
onClick={() => {
setIsLoading(true)
signIn('github')
}}
disabled={isLoading}
className={cn(className)}
{...props}
>
{isLoading ? (
<IconSpinner className="mr-2 animate-spin" />
) : (
<IconGitHub className="mr-2" />
)}
{text}
</Button>
)
}

View file

@ -1,10 +1,7 @@
'use client'
import * as React from 'react' import * as React from 'react'
import Link from 'next/link' import Link from 'next/link'
import Textarea from 'react-textarea-autosize' import Textarea from 'react-textarea-autosize'
import { useChatStore } from '@/lib/hooks/use-chat-store'
import { useEnterSubmit } from '@/lib/hooks/use-enter-submit' import { useEnterSubmit } from '@/lib/hooks/use-enter-submit'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Button, buttonVariants } from '@/components/ui/button' import { Button, buttonVariants } from '@/components/ui/button'
@ -15,15 +12,20 @@ import {
TooltipTrigger TooltipTrigger
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { IconArrowElbow, IconPlus } from '@/components/ui/icons' import { IconArrowElbow, IconPlus } from '@/components/ui/icons'
import { UseChatHelpers } from 'ai-connector'
export interface PromptProps { export interface PromptProps
extends Pick<UseChatHelpers, 'input' | 'setInput'> {
onSubmit: (value: string) => void onSubmit: (value: string) => void
isLoading: boolean isLoading: boolean
} }
export function PromptForm({ onSubmit, isLoading }: PromptProps) { export function PromptForm({
const { defaultMessage } = useChatStore() onSubmit,
const [input, setInput] = React.useState(defaultMessage) input,
setInput,
isLoading
}: PromptProps) {
const { formRef, onKeyDown } = useEnterSubmit() const { formRef, onKeyDown } = useEnterSubmit()
const inputRef = React.useRef<HTMLTextAreaElement>(null) const inputRef = React.useRef<HTMLTextAreaElement>(null)
@ -33,10 +35,6 @@ export function PromptForm({ onSubmit, isLoading }: PromptProps) {
} }
}, []) }, [])
React.useEffect(() => {
setInput(defaultMessage)
}, [defaultMessage])
return ( return (
<form <form
onSubmit={async e => { onSubmit={async e => {

View file

@ -1,13 +1,30 @@
import { getChats } from '@/app/actions' import { getChats } from '@/app/actions'
import { Session } from '@auth/core/types' import { Session } from '@auth/core/types'
import { SidebarItem } from './sidebar-item'
import { SidebarItem } from '@/components/sidebar-item'
import { LoginButton } from '@/components/login-button'
export interface SidebarListProps { export interface SidebarListProps {
session?: Session session?: Session
} }
export async function SidebarList(props: SidebarListProps) { export async function SidebarList({ session }: SidebarListProps) {
const chats = await getChats(props.session?.user?.email) if (!session?.user?.email) {
return (
<div className="flex-1 p-6 flex text-sm items-center flex-col space-y-4">
<div className="space-y-1 text-center">
<p className="font-medium">You are not logged in!</p>
<p className="text-muted-foreground">
Please login for chat history.
</p>
</div>
<LoginButton />
</div>
)
}
const chats = await getChats(session.user.email)
return ( return (
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{chats?.length ? ( {chats?.length ? (
@ -16,7 +33,7 @@ export async function SidebarList(props: SidebarListProps) {
<SidebarItem <SidebarItem
key={chat.id} key={chat.id}
title={chat.title} title={chat.title}
userId={props?.session?.user?.email ?? ''} userId={session?.user?.email ?? ''}
href={`/chat/${chat.id}`} href={`/chat/${chat.id}`}
id={chat.id} id={chat.id}
/> />
@ -24,13 +41,7 @@ export async function SidebarList(props: SidebarListProps) {
</div> </div>
) : ( ) : (
<div className="p-8 text-center"> <div className="p-8 text-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">No chat history</p>
{props?.session?.user ? (
<>No chat history</>
) : (
<>Login for history</>
)}
</p>
</div> </div>
)} )}
</div> </div>

View file

@ -29,10 +29,7 @@ export function Sidebar({ session, children }: SidebarProps) {
<span className="sr-only">Toggle Sidebar</span> <span className="sr-only">Toggle Sidebar</span>
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent <SheetContent className="inset-y-0 flex h-auto w-[300px] flex-col p-0">
position="left"
className="inset-y-0 flex h-auto w-[300px] flex-col gap-0 rounded-r-lg p-0 lg:inset-y-2"
>
<SheetHeader className="p-4"> <SheetHeader className="p-4">
<SheetTitle className="text-sm">Chat History</SheetTitle> <SheetTitle className="text-sm">Chat History</SheetTitle>
</SheetHeader> </SheetHeader>

View file

@ -2,7 +2,6 @@
import * as React from 'react' import * as React from 'react'
import * as SheetPrimitive from '@radix-ui/react-dialog' import * as SheetPrimitive from '@radix-ui/react-dialog'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { IconClose } from '@/components/ui/icons' import { IconClose } from '@/components/ui/icons'
@ -42,33 +41,17 @@ const SheetOverlay = React.forwardRef<
)) ))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
'fixed z-50 h-full scale-100 gap-4 border bg-background p-6 opacity-100 shadow-lg',
{
variants: {
position: {
left: 'data-[state=closed]:animate-slide-to-left data-[state=open]:animate-slide-from-left',
right: 'h-full animate-in slide-in-from-right duration-300'
}
},
defaultVariants: {
position: 'left'
}
}
)
export interface DialogContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef< const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>, React.ElementRef<typeof SheetPrimitive.Content>,
DialogContentProps React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>
>(({ position, className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => (
<SheetPortal> <SheetPortal>
<SheetPrimitive.Content <SheetPrimitive.Content
ref={ref} ref={ref}
className={cn(sheetVariants({ position }), className)} className={cn(
'fixed z-50 h-full border-r bg-background p-6 opacity-100 shadow-lg data-[state=closed]:animate-slide-to-left data-[state=open]:animate-slide-from-left',
className
)}
{...props} {...props}
> >
{children} {children}
@ -85,13 +68,7 @@ const SheetHeader = ({
className, className,
...props ...props
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div className={cn('flex flex-col space-y-2', className)} {...props} />
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className
)}
{...props}
/>
) )
SheetHeader.displayName = 'SheetHeader' SheetHeader.displayName = 'SheetHeader'

View file

@ -1,37 +1,24 @@
'use client' 'use client'
import * as React from 'react' import * as React from 'react'
import { signIn } from '@auth/nextjs/client'
import { type Session } from '@auth/nextjs/types' import { type Session } from '@auth/nextjs/types'
import { Button } from '@/components/ui/button' import { LoginButton } from '@/components/login-button'
import { IconSpinner } from '@/components/ui/icons'
export interface UserMenuProps { export interface UserMenuProps {
session?: Session session?: Session
} }
export function UserMenu({ session }: UserMenuProps) { export function UserMenu({ session }: UserMenuProps) {
const [isLoading, setIsLoading] = React.useState(false)
if (!session?.user) { if (!session?.user) {
return ( return (
<Button <LoginButton variant="ghost" className="[&_svg]:hidden" text="Login" />
variant="ghost"
size="sm"
onClick={() => {
setIsLoading(true)
signIn('github')
}}
disabled={isLoading}
>
{isLoading && <IconSpinner className="mr-2 animate-spin" />}
Login
</Button>
) )
} }
return ( return (
<p className="px-2 text-sm font-medium">Logged in as {session.user.name}</p> <p className="px-2 text-sm font-medium truncate w-[100px] sm:w-auto">
{session.user.name}
</p>
) )
} }

View file

@ -1,11 +0,0 @@
import { create } from 'zustand'
interface UseChatStore {
defaultMessage: string
setDefaultMessage: (message: string) => void
}
export const useChatStore = create<UseChatStore>()(set => ({
defaultMessage: '',
setDefaultMessage: message => set({ defaultMessage: message })
}))

View file

@ -39,8 +39,7 @@
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
"react-textarea-autosize": "^8.4.1", "react-textarea-autosize": "^8.4.1",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"remark-math": "^5.1.1", "remark-math": "^5.1.1"
"zustand": "^4.3.8"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.9", "@tailwindcss/typography": "^0.5.9",

19
pnpm-lock.yaml generated
View file

@ -86,9 +86,6 @@ dependencies:
remark-math: remark-math:
specifier: ^5.1.1 specifier: ^5.1.1
version: 5.1.1 version: 5.1.1
zustand:
specifier: ^4.3.8
version: 4.3.8(react@18.2.0)
devDependencies: devDependencies:
'@tailwindcss/typography': '@tailwindcss/typography':
@ -5070,22 +5067,6 @@ packages:
/zod@3.21.4: /zod@3.21.4:
resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==} resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==}
/zustand@4.3.8(react@18.2.0):
resolution: {integrity: sha512-4h28KCkHg5ii/wcFFJ5Fp+k1J3gJoasaIbppdgZFO4BPJnsNxL0mQXBSFgOgAdCdBj35aDTPvdAJReTMntFPGg==}
engines: {node: '>=12.7.0'}
peerDependencies:
immer: '>=9.0'
react: '>=16.8'
peerDependenciesMeta:
immer:
optional: true
react:
optional: true
dependencies:
react: 18.2.0
use-sync-external-store: 1.2.0(react@18.2.0)
dev: false
/zwitch@2.0.4: /zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
dev: false dev: false