Improve sidebar, new chat, and share dialog (#190)

This commit is contained in:
Jared Palmer 2023-12-04 12:42:53 -05:00 committed by GitHub
parent 35e83dc87e
commit be90a40427
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 598 additions and 217 deletions

17
app/(chat)/layout.tsx Normal file
View file

@ -0,0 +1,17 @@
import { SidebarDesktop } from '@/components/sidebar-desktop'
interface ChatLayoutProps {
children: React.ReactNode
}
export default async function ChatLayout({ children }: ChatLayoutProps) {
return (
<div className="relative flex h-[calc(100vh_-_theme(spacing.16))] overflow-hidden">
{/* @ts-ignore */}
<SidebarDesktop />
<div className="group w-full overflow-auto pl-0 animate-in duration-300 ease-in-out peer-[[data-state=open]]:lg:pl-[250px] peer-[[data-state=open]]:xl:pl-[300px]">
{children}
</div>
</div>
)
}

View file

@ -75,7 +75,7 @@ export async function clearChats() {
const chats: string[] = await kv.zrange(`user:chat:${session.user.id}`, 0, -1)
if (!chats.length) {
return redirect('/')
return redirect('/')
}
const pipeline = kv.pipeline()
@ -100,15 +100,23 @@ export async function getSharedChat(id: string) {
return chat
}
export async function shareChat(chat: Chat) {
export async function shareChat(id: string) {
const session = await auth()
if (!session?.user?.id || session.user.id !== chat.userId) {
if (!session?.user?.id) {
return {
error: 'Unauthorized'
}
}
const chat = await kv.hgetall<Chat>(`chat:${id}`)
if (!chat || chat.userId !== session.user.id) {
return {
error: 'Something went wrong'
}
}
const payload = {
...chat,
sharePath: `/share/${chat.id}`

View file

@ -44,8 +44,14 @@ export default function RootLayout({ children }: RootLayoutProps) {
)}
>
<Toaster />
<Providers attribute="class" defaultTheme="system" enableSystem>
<Providers
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<div className="flex flex-col min-h-screen">
{/* @ts-ignore */}
<Header />
<main className="flex flex-col flex-1 bg-muted/50">{children}</main>
</div>

View file

@ -0,0 +1,46 @@
import * as React from 'react'
import Link from 'next/link'
import { cn } from '@/lib/utils'
import { SidebarList } from '@/components/sidebar-list'
import { buttonVariants } from '@/components/ui/button'
import { IconPlus } from '@/components/ui/icons'
interface ChatHistoryProps {
userId?: string
}
export async function ChatHistory({ userId }: ChatHistoryProps) {
return (
<div className="flex flex-col h-full">
<div className="px-2 my-4">
<Link
href="/"
className={cn(
buttonVariants({ variant: 'outline' }),
'h-10 w-full justify-start bg-zinc-50 px-4 shadow-none transition-colors hover:bg-zinc-200/40 dark:bg-zinc-900 dark:hover:bg-zinc-300/10'
)}
>
<IconPlus className="-translate-x-2 stroke-2" />
New Chat
</Link>
</div>
<React.Suspense
fallback={
<div className="flex flex-col flex-1 px-4 space-y-4 overflow-auto">
{Array.from({ length: 10 }).map((_, i) => (
<div
key={i}
className="w-full h-6 rounded-md shrink-0 animate-pulse bg-zinc-200 dark:bg-zinc-800"
/>
))}
</div>
}
>
{/* @ts-ignore */}
<SidebarList userId={userId} />
</React.Suspense>
</div>
)
}

View file

@ -1,10 +1,13 @@
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, IconStop } from '@/components/ui/icons'
import { IconRefresh, IconShare, IconStop } from '@/components/ui/icons'
import { FooterText } from '@/components/footer'
import { ChatShareDialog } from '@/components/chat-share-dialog'
export interface ChatPanelProps
extends Pick<
@ -18,10 +21,12 @@ export interface ChatPanelProps
| 'setInput'
> {
id?: string
title?: string
}
export function ChatPanel({
id,
title,
isLoading,
stop,
append,
@ -30,11 +35,13 @@ export function ChatPanel({
setInput,
messages
}: ChatPanelProps) {
const [shareDialogOpen, setShareDialogOpen] = React.useState(false)
return (
<div className="fixed inset-x-0 bottom-0 bg-gradient-to-b from-muted/10 from-10% to-muted/30 to-50%">
<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]">
<ButtonScrollToBottom />
<div className="mx-auto sm:max-w-2xl sm:px-4">
<div className="flex h-10 items-center justify-center">
<div className="flex items-center justify-center h-12">
{isLoading ? (
<Button
variant="outline"
@ -45,19 +52,39 @@ export function ChatPanel({
Stop generating
</Button>
) : (
messages?.length > 0 && (
<Button
variant="outline"
onClick={() => reload()}
className="bg-background"
>
<IconRefresh className="mr-2" />
Regenerate response
</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>
)
)}
</div>
<div className="space-y-4 border-t bg-background px-4 py-2 shadow-lg sm:rounded-t-xl sm:border md:py-4">
<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({

View file

@ -0,0 +1,109 @@
'use client'
import * as React from 'react'
import Link from 'next/link'
import { type DialogProps } from '@radix-ui/react-dialog'
import { toast } from 'react-hot-toast'
import { ServerActionResult, type Chat } from '@/lib/types'
import { cn } from '@/lib/utils'
import { badgeVariants } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { IconSpinner } from '@/components/ui/icons'
import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard'
interface ChatShareDialogProps extends DialogProps {
chat: Pick<Chat, 'id' | 'title' | 'messages'>
shareChat: (id: string) => ServerActionResult<Chat>
onCopy: () => void
}
export function ChatShareDialog({
chat,
shareChat,
onCopy,
...props
}: ChatShareDialogProps) {
const { copyToClipboard } = useCopyToClipboard({ timeout: 1000 })
const [isSharePending, startShareTransition] = React.useTransition()
const copyShareLink = React.useCallback(
async (chat: Chat) => {
if (!chat.sharePath) {
return toast.error('Could not copy share link to clipboard')
}
const url = new URL(window.location.href)
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'
}
})
},
[copyToClipboard, onCopy]
)
return (
<Dialog {...props}>
<DialogContent>
<DialogHeader>
<DialogTitle>Share link to chat</DialogTitle>
<DialogDescription>
Anyone with the URL will be able to view the shared chat.
</DialogDescription>
</DialogHeader>
<div className="p-4 space-y-1 text-sm border rounded-md">
<div className="font-medium">{chat.title}</div>
<div className="text-muted-foreground">
{chat.messages.length} messages
</div>
</div>
<DialogFooter className="items-center">
<Button
disabled={isSharePending}
onClick={() => {
// @ts-ignore
startShareTransition(async () => {
const result = await shareChat(chat.id)
if (result && 'error' in result) {
toast.error(result.error)
return
}
copyShareLink(result)
})
}}
>
{isSharePending ? (
<>
<IconSpinner className="mr-2 animate-spin" />
Copying...
</>
) : (
<>Copy link</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -20,10 +20,14 @@ import {
import { IconSpinner } from '@/components/ui/icons'
interface ClearHistoryProps {
isEnabled: boolean
clearChats: () => ServerActionResult<void>
}
export function ClearHistory({ clearChats }: ClearHistoryProps) {
export function ClearHistory({
isEnabled = false,
clearChats
}: ClearHistoryProps) {
const [open, setOpen] = React.useState(false)
const [isPending, startTransition] = React.useTransition()
const router = useRouter()
@ -31,7 +35,7 @@ export function ClearHistory({ clearChats }: ClearHistoryProps) {
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Button variant="ghost" disabled={isPending}>
<Button variant="ghost" disabled={!isEnabled || isPending}>
{isPending && <IconSpinner className="mr-2" />}
Clear history
</Button>
@ -50,16 +54,16 @@ export function ClearHistory({ clearChats }: ClearHistoryProps) {
disabled={isPending}
onClick={event => {
event.preventDefault()
startTransition(async () => {
const result = await clearChats()
startTransition(() => {
clearChats().then(result => {
if (result && 'error' in result) {
toast.error(result.error)
return
}
if (result && 'error' in result) {
toast.error(result.error)
return
}
setOpen(false)
router.push('/')
setOpen(false)
router.push('/')
})
})
}}
>

View file

@ -17,21 +17,21 @@ import { SidebarFooter } from '@/components/sidebar-footer'
import { ThemeToggle } from '@/components/theme-toggle'
import { ClearHistory } from '@/components/clear-history'
import { UserMenu } from '@/components/user-menu'
import { SidebarMobile } from './sidebar-mobile'
import { SidebarToggle } from './sidebar-toggle'
import { ChatHistory } from './chat-history'
async function UserOrLogin() {
const session = await auth()
return (
<>
{session?.user ? (
<Sidebar>
<React.Suspense fallback={<div className="flex-1 overflow-auto" />}>
<SidebarList userId={session?.user?.id} />
</React.Suspense>
<SidebarFooter>
<ThemeToggle />
<ClearHistory clearChats={clearChats} />
</SidebarFooter>
</Sidebar>
<>
<SidebarMobile>
<ChatHistory userId={session.user.id} />
</SidebarMobile>
<SidebarToggle />
</>
) : (
<Link href="/" target="_blank" rel="nofollow">
<IconNextChat className="w-6 h-6 mr-2 dark:hidden" inverted />

View file

@ -1,21 +1,20 @@
import { UseChatHelpers } from 'ai/react'
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 { IconArrowElbow, IconPlus } from '@/components/ui/icons'
import {
Tooltip,
TooltipContent,
TooltipTrigger
} from '@/components/ui/tooltip'
import { useEnterSubmit } from '@/lib/hooks/use-enter-submit'
import { cn } from '@/lib/utils'
import { IconArrowElbow, IconPlus } from '@/components/ui/icons'
import { useRouter } from 'next/navigation'
export interface PromptProps
extends Pick<UseChatHelpers, 'input' | 'setInput'> {
onSubmit: (value: string) => Promise<void>
onSubmit: (value: string) => void
isLoading: boolean
}
@ -28,7 +27,6 @@ export function PromptForm({
const { formRef, onKeyDown } = useEnterSubmit()
const inputRef = React.useRef<HTMLTextAreaElement>(null)
const router = useRouter()
React.useEffect(() => {
if (inputRef.current) {
inputRef.current.focus()
@ -47,7 +45,7 @@ export function PromptForm({
}}
ref={formRef}
>
<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">
<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">
<Tooltip>
<TooltipTrigger asChild>
<button

View file

@ -3,13 +3,15 @@
import * as React from 'react'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { ThemeProviderProps } from 'next-themes/dist/types'
import { SidebarProvider } from '@/lib/hooks/use-sidebar'
import { TooltipProvider } from '@/components/ui/tooltip'
export function Providers({ children, ...props }: ThemeProviderProps) {
return (
<NextThemesProvider {...props}>
<TooltipProvider>{children}</TooltipProvider>
<SidebarProvider>
<TooltipProvider>{children}</TooltipProvider>
</SidebarProvider>
</NextThemesProvider>
)
}

View file

@ -1,11 +1,10 @@
'use client'
import * as React from 'react'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { toast } from 'react-hot-toast'
import { type Chat, ServerActionResult } from '@/lib/types'
import { cn, formatDate } from '@/lib/utils'
import { ServerActionResult, type Chat } from '@/lib/types'
import {
AlertDialog,
AlertDialogAction,
@ -17,22 +16,8 @@ import {
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
IconShare,
IconSpinner,
IconTrash,
IconUsers
} from '@/components/ui/icons'
import Link from 'next/link'
import { badgeVariants } from '@/components/ui/badge'
import { IconShare, IconSpinner, IconTrash } from '@/components/ui/icons'
import { ChatShareDialog } from '@/components/chat-share-dialog'
import {
Tooltip,
TooltipContent,
@ -42,7 +27,7 @@ import {
interface SidebarActionsProps {
chat: Chat
removeChat: (args: { id: string; path: string }) => ServerActionResult<void>
shareChat: (chat: Chat) => ServerActionResult<Chat>
shareChat: (id: string) => ServerActionResult<Chat>
}
export function SidebarActions({
@ -50,34 +35,10 @@ export function SidebarActions({
removeChat,
shareChat
}: SidebarActionsProps) {
const router = useRouter()
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false)
const [shareDialogOpen, setShareDialogOpen] = React.useState(false)
const [isRemovePending, startRemoveTransition] = React.useTransition()
const [isSharePending, startShareTransition] = React.useTransition()
const router = useRouter()
const copyShareLink = React.useCallback(async (chat: Chat) => {
if (!chat.sharePath) {
return toast.error('Could not copy share link to clipboard')
}
const url = new URL(window.location.href)
url.pathname = chat.sharePath
navigator.clipboard.writeText(url.toString())
setShareDialogOpen(false)
toast.success('Share link copied to clipboard', {
style: {
borderRadius: '10px',
background: '#333',
color: '#fff',
fontSize: '14px'
},
iconTheme: {
primary: 'white',
secondary: 'black'
}
})
}, [])
return (
<>
@ -86,7 +47,7 @@ export function SidebarActions({
<TooltipTrigger asChild>
<Button
variant="ghost"
className="h-6 w-6 p-0 hover:bg-background"
className="w-6 h-6 p-0 hover:bg-background"
onClick={() => setShareDialogOpen(true)}
>
<IconShare />
@ -99,7 +60,7 @@ export function SidebarActions({
<TooltipTrigger asChild>
<Button
variant="ghost"
className="h-6 w-6 p-0 hover:bg-background"
className="w-6 h-6 p-0 hover:bg-background"
disabled={isRemovePending}
onClick={() => setDeleteDialogOpen(true)}
>
@ -110,68 +71,13 @@ export function SidebarActions({
<TooltipContent>Delete chat</TooltipContent>
</Tooltip>
</div>
<Dialog open={shareDialogOpen} onOpenChange={setShareDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Share link to chat</DialogTitle>
<DialogDescription>
Anyone with the URL will be able to view the shared chat.
</DialogDescription>
</DialogHeader>
<div className="space-y-1 rounded-md border p-4 text-sm">
<div className="font-medium">{chat.title}</div>
<div className="text-muted-foreground">
{formatDate(Number(chat.createdAt))} · {chat.messages.length}{' '}
messages
</div>
</div>
<DialogFooter className="items-center">
{chat.sharePath && (
<Link
href={chat.sharePath}
className={cn(
badgeVariants({ variant: 'secondary' }),
'mr-auto'
)}
target="_blank"
>
<IconUsers className="mr-2" />
{chat.sharePath}
</Link>
)}
<Button
disabled={isSharePending}
onClick={() => {
startShareTransition(async () => {
if (chat.sharePath) {
await new Promise(resolve => setTimeout(resolve, 500))
copyShareLink(chat)
return
}
const result = await shareChat(chat)
if (result && 'error' in result) {
toast.error(result.error)
return
}
copyShareLink(result)
})
}}
>
{isSharePending ? (
<>
<IconSpinner className="mr-2 animate-spin" />
Copying...
</>
) : (
<>Copy link</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ChatShareDialog
chat={chat}
shareChat={shareChat}
open={shareDialogOpen}
onOpenChange={setShareDialogOpen}
onCopy={() => setShareDialogOpen(false)}
/>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
@ -189,6 +95,7 @@ export function SidebarActions({
disabled={isRemovePending}
onClick={event => {
event.preventDefault()
// @ts-ignore
startRemoveTransition(async () => {
const result = await removeChat({
id: chat.id,

View file

@ -0,0 +1,19 @@
import { Sidebar } from '@/components/sidebar'
import { auth } from '@/auth'
import { ChatHistory } from '@/components/chat-history'
export async function SidebarDesktop() {
const session = await auth()
if (!session?.user?.id) {
return null
}
return (
<Sidebar className="peer absolute inset-y-0 z-30 hidden -translate-x-full border-r bg-muted duration-300 ease-in-out data-[state=open]:translate-x-0 lg:flex lg:w-[250px] xl:w-[300px]">
{/* @ts-ignore */}
<ChatHistory userId={session.user.id} />
</Sidebar>
)
}

View file

@ -1,10 +1,12 @@
'use client'
import * as React from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { type Chat } from '@/lib/types'
import { cn } from '@/lib/utils'
import { motion } from 'framer-motion'
import { buttonVariants } from '@/components/ui/button'
import { IconMessage, IconUsers } from '@/components/ui/icons'
import {
@ -12,20 +14,45 @@ import {
TooltipContent,
TooltipTrigger
} from '@/components/ui/tooltip'
import { useLocalStorage } from '@/lib/hooks/use-local-storage'
import { type Chat } from '@/lib/types'
import { cn } from '@/lib/utils'
interface SidebarItemProps {
index: number
chat: Chat
children: React.ReactNode
}
export function SidebarItem({ chat, children }: SidebarItemProps) {
export function SidebarItem({ index, chat, children }: SidebarItemProps) {
const pathname = usePathname()
const isActive = pathname === chat.path
const [newChatId, setNewChatId] = useLocalStorage('newChatId', null)
const shouldAnimate = index === 0 && isActive && newChatId
if (!chat?.id) return null
return (
<div className="relative">
<motion.div
className="relative h-8"
variants={{
initial: {
height: 0,
opacity: 0
},
animate: {
height: 'auto',
opacity: 1
}
}}
initial={shouldAnimate ? 'initial' : undefined}
animate={shouldAnimate ? 'animate' : undefined}
transition={{
duration: 0.25,
ease: 'easeIn'
}}
>
<div className="absolute left-2 top-1 flex h-6 w-6 items-center justify-center">
{chat.sharePath ? (
<Tooltip delayDuration={1000}>
@ -45,18 +72,53 @@ export function SidebarItem({ chat, children }: SidebarItemProps) {
href={chat.path}
className={cn(
buttonVariants({ variant: 'ghost' }),
'group w-full pl-8 pr-16',
isActive && 'bg-accent'
'group w-full px-8 transition-colors hover:bg-zinc-200/40 dark:hover:bg-zinc-300/10',
isActive && 'bg-zinc-200 pr-16 font-semibold dark:bg-zinc-800'
)}
>
<div
className="relative max-h-5 flex-1 select-none overflow-hidden text-ellipsis break-all"
title={chat.title}
>
<span className="whitespace-nowrap">{chat.title}</span>
<span className="whitespace-nowrap">
{shouldAnimate ? (
chat.title.split('').map((character, index) => (
<motion.span
key={index}
variants={{
initial: {
opacity: 0,
x: -100
},
animate: {
opacity: 1,
x: 0
}
}}
initial={shouldAnimate ? 'initial' : undefined}
animate={shouldAnimate ? 'animate' : undefined}
transition={{
duration: 0.25,
ease: 'easeIn',
delay: index * 0.05,
staggerChildren: 0.05
}}
onAnimationComplete={() => {
if (index === chat.title.length - 1) {
setNewChatId(null)
}
}}
>
{character}
</motion.span>
))
) : (
<span>{chat.title}</span>
)}
</span>
</div>
</Link>
{isActive && <div className="absolute right-2 top-1">{children}</div>}
</div>
</motion.div>
)
}

View file

@ -0,0 +1,42 @@
'use client'
import { Chat } from '@/lib/types'
import { AnimatePresence, motion } from 'framer-motion'
import { removeChat, shareChat } from '@/app/actions'
import { SidebarActions } from '@/components/sidebar-actions'
import { SidebarItem } from '@/components/sidebar-item'
interface SidebarItemsProps {
chats?: Chat[]
}
export function SidebarItems({ chats }: SidebarItemsProps) {
if (!chats?.length) return null
return (
<AnimatePresence>
{chats.map(
(chat, index) =>
chat && (
<motion.div
key={chat?.id}
exit={{
opacity: 0,
height: 0
}}
>
<SidebarItem index={index} chat={chat}>
<SidebarActions
chat={chat}
removeChat={removeChat}
shareChat={shareChat}
/>
</SidebarItem>
</motion.div>
)
)}
</AnimatePresence>
)
}

View file

@ -1,36 +1,38 @@
import { getChats, removeChat, shareChat } from '@/app/actions'
import { SidebarActions } from '@/components/sidebar-actions'
import { SidebarItem } from '@/components/sidebar-item'
import { clearChats, getChats } from '@/app/actions'
import { ClearHistory } from '@/components/clear-history'
import { SidebarItems } from '@/components/sidebar-items'
import { ThemeToggle } from '@/components/theme-toggle'
import { cache } from 'react'
export interface SidebarListProps {
interface SidebarListProps {
userId?: string
children?: React.ReactNode
}
const loadChats = cache(async (userId?: string) => {
return await getChats(userId)
})
export async function SidebarList({ userId }: SidebarListProps) {
const chats = await getChats(userId)
const chats = await loadChats(userId)
return (
<div className="flex-1 overflow-auto">
{chats?.length ? (
<div className="space-y-2 px-2">
{chats.map(
chat =>
chat && (
<SidebarItem key={chat?.id} chat={chat}>
<SidebarActions
chat={chat}
removeChat={removeChat}
shareChat={shareChat}
/>
</SidebarItem>
)
)}
</div>
) : (
<div className="p-8 text-center">
<p className="text-sm text-muted-foreground">No chat history</p>
</div>
)}
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex-1 overflow-auto">
{chats?.length ? (
<div className="space-y-2 px-2">
<SidebarItems chats={chats} />
</div>
) : (
<div className="p-8 text-center">
<p className="text-sm text-muted-foreground">No chat history</p>
</div>
)}
</div>
<div className="flex items-center justify-between p-4">
<ThemeToggle />
<ClearHistory clearChats={clearChats} isEnabled={chats?.length > 0} />
</div>
</div>
)
}

View file

@ -0,0 +1,28 @@
'use client'
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'
import { Sidebar } from '@/components/sidebar'
import { Button } from '@/components/ui/button'
import { IconSidebar } from '@/components/ui/icons'
interface SidebarMobileProps {
children: React.ReactNode
}
export function SidebarMobile({ children }: SidebarMobileProps) {
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" className="-ml-2 flex h-9 w-9 p-0 lg:hidden">
<IconSidebar className="h-6 w-6" />
<span className="sr-only">Toggle Sidebar</span>
</Button>
</SheetTrigger>
<SheetContent 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,24 @@
'use client'
import * as React from 'react'
import { useSidebar } from '@/lib/hooks/use-sidebar'
import { Button } from '@/components/ui/button'
import { IconSidebar } from '@/components/ui/icons'
export function SidebarToggle() {
const { toggleSidebar } = useSidebar()
return (
<Button
variant="ghost"
className="-ml-2 hidden h-9 w-9 p-0 lg:flex"
onClick={() => {
toggleSidebar()
}}
>
<IconSidebar className="h-6 w-6" />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}

View file

@ -2,35 +2,20 @@
import * as React from 'react'
import { Button } from '@/components/ui/button'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger
} from '@/components/ui/sheet'
import { IconSidebar } from '@/components/ui/icons'
import { useSidebar } from '@/lib/hooks/use-sidebar'
import { cn } from '@/lib/utils'
export interface SidebarProps {
children?: React.ReactNode
}
export interface SidebarProps extends React.ComponentProps<'div'> {}
export function Sidebar({ className, children }: SidebarProps) {
const { isSidebarOpen, isLoading } = useSidebar()
export function Sidebar({ children }: SidebarProps) {
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" className="-ml-2 h-9 w-9 p-0">
<IconSidebar className="h-6 w-6" />
<span className="sr-only">Toggle Sidebar</span>
</Button>
</SheetTrigger>
<SheetContent className="inset-y-0 flex h-auto w-[300px] flex-col p-0">
<SheetHeader className="p-4">
<SheetTitle className="text-sm">Chat History</SheetTitle>
</SheetHeader>
{children}
</SheetContent>
</Sheet>
<div
data-state={isSidebarOpen && !isLoading ? 'open' : 'closed'}
className={cn(className, 'h-full flex-col dark:bg-zinc-950')}
>
{children}
</div>
)
}

60
lib/hooks/use-sidebar.tsx Normal file
View file

@ -0,0 +1,60 @@
'use client'
import * as React from 'react'
const LOCAL_STORAGE_KEY = 'sidebar'
interface SidebarContext {
isSidebarOpen: boolean
toggleSidebar: () => void
isLoading: boolean
}
const SidebarContext = React.createContext<SidebarContext | undefined>(
undefined
)
export function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error('useSidebarContext must be used within a SidebarProvider')
}
return context
}
interface SidebarProviderProps {
children: React.ReactNode
}
export function SidebarProvider({ children }: SidebarProviderProps) {
const [isSidebarOpen, setSidebarOpen] = React.useState(true)
const [isLoading, setLoading] = React.useState(true)
React.useEffect(() => {
const value = localStorage.getItem(LOCAL_STORAGE_KEY)
if (value) {
setSidebarOpen(JSON.parse(value))
}
setLoading(false)
}, [])
const toggleSidebar = () => {
setSidebarOpen(value => {
const newState = !value
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(newState))
return newState
})
}
if (isLoading) {
return null
}
return (
<SidebarContext.Provider
value={{ isSidebarOpen, toggleSidebar, isLoading }}
>
{children}
</SidebarContext.Provider>
)
}

View file

@ -28,6 +28,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"focus-trap-react": "^10.2.3",
"framer-motion": "^10.16.12",
"geist": "^1.1.0",
"nanoid": "^5.0.3",
"next": "14.0.4-canary.17",

34
pnpm-lock.yaml generated
View file

@ -53,6 +53,9 @@ dependencies:
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)
framer-motion:
specifier: ^10.16.12
version: 10.16.12(react-dom@18.2.0)(react@18.2.0)
geist:
specifier: ^1.1.0
version: 1.1.0(next@14.0.4-canary.17)
@ -221,6 +224,19 @@ packages:
to-fast-properties: 2.0.0
dev: false
/@emotion/is-prop-valid@0.8.8:
resolution: {integrity: sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==}
requiresBuild: true
dependencies:
'@emotion/memoize': 0.7.4
dev: false
optional: true
/@emotion/memoize@0.7.4:
resolution: {integrity: sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==}
dev: false
optional: true
/@eslint-community/eslint-utils@4.4.0(eslint@8.54.0):
resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@ -2618,6 +2634,24 @@ packages:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
dev: true
/framer-motion@10.16.12(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-w7Yzx0OzQ5Uh6uNkxaX+4TuAPuOKz3haSbjmHpdrqDpGuCJCpq6YP9Dy7JJWdZ6mJjndrg3Ao3vUwDajKNikCA==}
peerDependencies:
react: ^18.0.0
react-dom: ^18.0.0
peerDependenciesMeta:
react:
optional: true
react-dom:
optional: true
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
tslib: 2.6.2
optionalDependencies:
'@emotion/is-prop-valid': 0.8.8
dev: false
/fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
dev: true