Skip to content

Commit

Permalink
Improve sidebar, new chat, and share dialog (vercel#190)
Browse files Browse the repository at this point in the history
  • Loading branch information
jaredpalmer authored Dec 4, 2023
1 parent 35e83dc commit be90a40
Show file tree
Hide file tree
Showing 23 changed files with 598 additions and 217 deletions.
File renamed without changes.
17 changes: 17 additions & 0 deletions app/(chat)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
File renamed without changes.
14 changes: 11 additions & 3 deletions app/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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}`
Expand Down
8 changes: 7 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
46 changes: 46 additions & 0 deletions components/chat-history.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
53 changes: 40 additions & 13 deletions components/chat-panel.tsx
Original file line number Diff line number Diff line change
@@ -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<
Expand All @@ -18,10 +21,12 @@ export interface ChatPanelProps
| 'setInput'
> {
id?: string
title?: string
}

export function ChatPanel({
id,
title,
isLoading,
stop,
append,
Expand All @@ -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"
Expand All @@ -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({
Expand Down
109 changes: 109 additions & 0 deletions components/chat-share-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
26 changes: 15 additions & 11 deletions components/clear-history.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,22 @@ 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()

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>
Expand All @@ -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('/')
})
})
}}
>
Expand Down
Loading

0 comments on commit be90a40

Please sign in to comment.