Improve sidebar, new chat, and share dialog (#190)
This commit is contained in:
parent
35e83dc87e
commit
be90a40427
23 changed files with 598 additions and 217 deletions
17
app/(chat)/layout.tsx
Normal file
17
app/(chat)/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
46
components/chat-history.tsx
Normal file
46
components/chat-history.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
109
components/chat-share-dialog.tsx
Normal file
109
components/chat-share-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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('/')
|
||||
})
|
||||
})
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
19
components/sidebar-desktop.tsx
Normal file
19
components/sidebar-desktop.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
42
components/sidebar-items.tsx
Normal file
42
components/sidebar-items.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
28
components/sidebar-mobile.tsx
Normal file
28
components/sidebar-mobile.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
components/sidebar-toggle.tsx
Normal file
24
components/sidebar-toggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
60
lib/hooks/use-sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
34
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue