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)
|
const chats: string[] = await kv.zrange(`user:chat:${session.user.id}`, 0, -1)
|
||||||
if (!chats.length) {
|
if (!chats.length) {
|
||||||
return redirect('/')
|
return redirect('/')
|
||||||
}
|
}
|
||||||
const pipeline = kv.pipeline()
|
const pipeline = kv.pipeline()
|
||||||
|
|
||||||
|
|
@ -100,15 +100,23 @@ export async function getSharedChat(id: string) {
|
||||||
return chat
|
return chat
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function shareChat(chat: Chat) {
|
export async function shareChat(id: string) {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
|
|
||||||
if (!session?.user?.id || session.user.id !== chat.userId) {
|
if (!session?.user?.id) {
|
||||||
return {
|
return {
|
||||||
error: 'Unauthorized'
|
error: 'Unauthorized'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chat = await kv.hgetall<Chat>(`chat:${id}`)
|
||||||
|
|
||||||
|
if (!chat || chat.userId !== session.user.id) {
|
||||||
|
return {
|
||||||
|
error: 'Something went wrong'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
...chat,
|
...chat,
|
||||||
sharePath: `/share/${chat.id}`
|
sharePath: `/share/${chat.id}`
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,14 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<Providers attribute="class" defaultTheme="system" enableSystem>
|
<Providers
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
<div className="flex flex-col min-h-screen">
|
<div className="flex flex-col min-h-screen">
|
||||||
|
{/* @ts-ignore */}
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex flex-col flex-1 bg-muted/50">{children}</main>
|
<main className="flex flex-col flex-1 bg-muted/50">{children}</main>
|
||||||
</div>
|
</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 { type UseChatHelpers } from 'ai/react'
|
||||||
|
|
||||||
|
import { shareChat } from '@/app/actions'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { PromptForm } from '@/components/prompt-form'
|
import { PromptForm } from '@/components/prompt-form'
|
||||||
import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom'
|
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 { FooterText } from '@/components/footer'
|
||||||
|
import { ChatShareDialog } from '@/components/chat-share-dialog'
|
||||||
|
|
||||||
export interface ChatPanelProps
|
export interface ChatPanelProps
|
||||||
extends Pick<
|
extends Pick<
|
||||||
|
|
@ -18,10 +21,12 @@ export interface ChatPanelProps
|
||||||
| 'setInput'
|
| 'setInput'
|
||||||
> {
|
> {
|
||||||
id?: string
|
id?: string
|
||||||
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatPanel({
|
export function ChatPanel({
|
||||||
id,
|
id,
|
||||||
|
title,
|
||||||
isLoading,
|
isLoading,
|
||||||
stop,
|
stop,
|
||||||
append,
|
append,
|
||||||
|
|
@ -30,11 +35,13 @@ export function ChatPanel({
|
||||||
setInput,
|
setInput,
|
||||||
messages
|
messages
|
||||||
}: ChatPanelProps) {
|
}: ChatPanelProps) {
|
||||||
|
const [shareDialogOpen, setShareDialogOpen] = React.useState(false)
|
||||||
|
|
||||||
return (
|
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 />
|
<ButtonScrollToBottom />
|
||||||
<div className="mx-auto sm:max-w-2xl sm:px-4">
|
<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 ? (
|
{isLoading ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -45,19 +52,39 @@ export function ChatPanel({
|
||||||
Stop generating
|
Stop generating
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
messages?.length > 0 && (
|
messages?.length >= 2 && (
|
||||||
<Button
|
<div className="flex space-x-2">
|
||||||
variant="outline"
|
<Button variant="outline" onClick={() => reload()}>
|
||||||
onClick={() => reload()}
|
<IconRefresh className="mr-2" />
|
||||||
className="bg-background"
|
Regenerate response
|
||||||
>
|
</Button>
|
||||||
<IconRefresh className="mr-2" />
|
{id && title ? (
|
||||||
Regenerate response
|
<>
|
||||||
</Button>
|
<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>
|
||||||
<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
|
<PromptForm
|
||||||
onSubmit={async value => {
|
onSubmit={async value => {
|
||||||
await append({
|
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'
|
import { IconSpinner } from '@/components/ui/icons'
|
||||||
|
|
||||||
interface ClearHistoryProps {
|
interface ClearHistoryProps {
|
||||||
|
isEnabled: boolean
|
||||||
clearChats: () => ServerActionResult<void>
|
clearChats: () => ServerActionResult<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClearHistory({ clearChats }: ClearHistoryProps) {
|
export function ClearHistory({
|
||||||
|
isEnabled = false,
|
||||||
|
clearChats
|
||||||
|
}: ClearHistoryProps) {
|
||||||
const [open, setOpen] = React.useState(false)
|
const [open, setOpen] = React.useState(false)
|
||||||
const [isPending, startTransition] = React.useTransition()
|
const [isPending, startTransition] = React.useTransition()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -31,7 +35,7 @@ export function ClearHistory({ clearChats }: ClearHistoryProps) {
|
||||||
return (
|
return (
|
||||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="ghost" disabled={isPending}>
|
<Button variant="ghost" disabled={!isEnabled || isPending}>
|
||||||
{isPending && <IconSpinner className="mr-2" />}
|
{isPending && <IconSpinner className="mr-2" />}
|
||||||
Clear history
|
Clear history
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -50,16 +54,16 @@ export function ClearHistory({ clearChats }: ClearHistoryProps) {
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
onClick={event => {
|
onClick={event => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
startTransition(async () => {
|
startTransition(() => {
|
||||||
const result = await clearChats()
|
clearChats().then(result => {
|
||||||
|
if (result && 'error' in result) {
|
||||||
|
toast.error(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (result && 'error' in result) {
|
setOpen(false)
|
||||||
toast.error(result.error)
|
router.push('/')
|
||||||
return
|
})
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(false)
|
|
||||||
router.push('/')
|
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -17,21 +17,21 @@ import { SidebarFooter } from '@/components/sidebar-footer'
|
||||||
import { ThemeToggle } from '@/components/theme-toggle'
|
import { ThemeToggle } from '@/components/theme-toggle'
|
||||||
import { ClearHistory } from '@/components/clear-history'
|
import { ClearHistory } from '@/components/clear-history'
|
||||||
import { UserMenu } from '@/components/user-menu'
|
import { UserMenu } from '@/components/user-menu'
|
||||||
|
import { SidebarMobile } from './sidebar-mobile'
|
||||||
|
import { SidebarToggle } from './sidebar-toggle'
|
||||||
|
import { ChatHistory } from './chat-history'
|
||||||
|
|
||||||
async function UserOrLogin() {
|
async function UserOrLogin() {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{session?.user ? (
|
{session?.user ? (
|
||||||
<Sidebar>
|
<>
|
||||||
<React.Suspense fallback={<div className="flex-1 overflow-auto" />}>
|
<SidebarMobile>
|
||||||
<SidebarList userId={session?.user?.id} />
|
<ChatHistory userId={session.user.id} />
|
||||||
</React.Suspense>
|
</SidebarMobile>
|
||||||
<SidebarFooter>
|
<SidebarToggle />
|
||||||
<ThemeToggle />
|
</>
|
||||||
<ClearHistory clearChats={clearChats} />
|
|
||||||
</SidebarFooter>
|
|
||||||
</Sidebar>
|
|
||||||
) : (
|
) : (
|
||||||
<Link href="/" target="_blank" rel="nofollow">
|
<Link href="/" target="_blank" rel="nofollow">
|
||||||
<IconNextChat className="w-6 h-6 mr-2 dark:hidden" inverted />
|
<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 * as React from 'react'
|
||||||
import Textarea from 'react-textarea-autosize'
|
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 { Button, buttonVariants } from '@/components/ui/button'
|
||||||
import { IconArrowElbow, IconPlus } from '@/components/ui/icons'
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger
|
TooltipTrigger
|
||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
import { useEnterSubmit } from '@/lib/hooks/use-enter-submit'
|
import { IconArrowElbow, IconPlus } from '@/components/ui/icons'
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
export interface PromptProps
|
export interface PromptProps
|
||||||
extends Pick<UseChatHelpers, 'input' | 'setInput'> {
|
extends Pick<UseChatHelpers, 'input' | 'setInput'> {
|
||||||
onSubmit: (value: string) => Promise<void>
|
onSubmit: (value: string) => void
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,7 +27,6 @@ export function PromptForm({
|
||||||
const { formRef, onKeyDown } = useEnterSubmit()
|
const { formRef, onKeyDown } = useEnterSubmit()
|
||||||
const inputRef = React.useRef<HTMLTextAreaElement>(null)
|
const inputRef = React.useRef<HTMLTextAreaElement>(null)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (inputRef.current) {
|
if (inputRef.current) {
|
||||||
inputRef.current.focus()
|
inputRef.current.focus()
|
||||||
|
|
@ -47,7 +45,7 @@ export function PromptForm({
|
||||||
}}
|
}}
|
||||||
ref={formRef}
|
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>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,15 @@
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||||
import { ThemeProviderProps } from 'next-themes/dist/types'
|
import { ThemeProviderProps } from 'next-themes/dist/types'
|
||||||
|
import { SidebarProvider } from '@/lib/hooks/use-sidebar'
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
export function Providers({ children, ...props }: ThemeProviderProps) {
|
export function Providers({ children, ...props }: ThemeProviderProps) {
|
||||||
return (
|
return (
|
||||||
<NextThemesProvider {...props}>
|
<NextThemesProvider {...props}>
|
||||||
<TooltipProvider>{children}</TooltipProvider>
|
<SidebarProvider>
|
||||||
|
<TooltipProvider>{children}</TooltipProvider>
|
||||||
|
</SidebarProvider>
|
||||||
</NextThemesProvider>
|
</NextThemesProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import * as React from 'react'
|
||||||
import { toast } from 'react-hot-toast'
|
import { toast } from 'react-hot-toast'
|
||||||
|
|
||||||
import { type Chat, ServerActionResult } from '@/lib/types'
|
import { ServerActionResult, type Chat } from '@/lib/types'
|
||||||
import { cn, formatDate } from '@/lib/utils'
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
@ -17,22 +16,8 @@ import {
|
||||||
AlertDialogTitle
|
AlertDialogTitle
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import { IconShare, IconSpinner, IconTrash } from '@/components/ui/icons'
|
||||||
Dialog,
|
import { ChatShareDialog } from '@/components/chat-share-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 {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
|
|
@ -42,7 +27,7 @@ import {
|
||||||
interface SidebarActionsProps {
|
interface SidebarActionsProps {
|
||||||
chat: Chat
|
chat: Chat
|
||||||
removeChat: (args: { id: string; path: string }) => ServerActionResult<void>
|
removeChat: (args: { id: string; path: string }) => ServerActionResult<void>
|
||||||
shareChat: (chat: Chat) => ServerActionResult<Chat>
|
shareChat: (id: string) => ServerActionResult<Chat>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarActions({
|
export function SidebarActions({
|
||||||
|
|
@ -50,34 +35,10 @@ export function SidebarActions({
|
||||||
removeChat,
|
removeChat,
|
||||||
shareChat
|
shareChat
|
||||||
}: SidebarActionsProps) {
|
}: SidebarActionsProps) {
|
||||||
|
const router = useRouter()
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false)
|
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false)
|
||||||
const [shareDialogOpen, setShareDialogOpen] = React.useState(false)
|
const [shareDialogOpen, setShareDialogOpen] = React.useState(false)
|
||||||
const [isRemovePending, startRemoveTransition] = React.useTransition()
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -86,7 +47,7 @@ export function SidebarActions({
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
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)}
|
onClick={() => setShareDialogOpen(true)}
|
||||||
>
|
>
|
||||||
<IconShare />
|
<IconShare />
|
||||||
|
|
@ -99,7 +60,7 @@ export function SidebarActions({
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-6 w-6 p-0 hover:bg-background"
|
className="w-6 h-6 p-0 hover:bg-background"
|
||||||
disabled={isRemovePending}
|
disabled={isRemovePending}
|
||||||
onClick={() => setDeleteDialogOpen(true)}
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
>
|
>
|
||||||
|
|
@ -110,68 +71,13 @@ export function SidebarActions({
|
||||||
<TooltipContent>Delete chat</TooltipContent>
|
<TooltipContent>Delete chat</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={shareDialogOpen} onOpenChange={setShareDialogOpen}>
|
<ChatShareDialog
|
||||||
<DialogContent>
|
chat={chat}
|
||||||
<DialogHeader>
|
shareChat={shareChat}
|
||||||
<DialogTitle>Share link to chat</DialogTitle>
|
open={shareDialogOpen}
|
||||||
<DialogDescription>
|
onOpenChange={setShareDialogOpen}
|
||||||
Anyone with the URL will be able to view the shared chat.
|
onCopy={() => setShareDialogOpen(false)}
|
||||||
</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>
|
|
||||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
|
|
@ -189,6 +95,7 @@ export function SidebarActions({
|
||||||
disabled={isRemovePending}
|
disabled={isRemovePending}
|
||||||
onClick={event => {
|
onClick={event => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
// @ts-ignore
|
||||||
startRemoveTransition(async () => {
|
startRemoveTransition(async () => {
|
||||||
const result = await removeChat({
|
const result = await removeChat({
|
||||||
id: chat.id,
|
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'
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
|
|
||||||
import { type Chat } from '@/lib/types'
|
import { motion } from 'framer-motion'
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { buttonVariants } from '@/components/ui/button'
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
import { IconMessage, IconUsers } from '@/components/ui/icons'
|
import { IconMessage, IconUsers } from '@/components/ui/icons'
|
||||||
import {
|
import {
|
||||||
|
|
@ -12,20 +14,45 @@ import {
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger
|
TooltipTrigger
|
||||||
} from '@/components/ui/tooltip'
|
} 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 {
|
interface SidebarItemProps {
|
||||||
|
index: number
|
||||||
chat: Chat
|
chat: Chat
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarItem({ chat, children }: SidebarItemProps) {
|
export function SidebarItem({ index, chat, children }: SidebarItemProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
const isActive = pathname === chat.path
|
const isActive = pathname === chat.path
|
||||||
|
const [newChatId, setNewChatId] = useLocalStorage('newChatId', null)
|
||||||
|
const shouldAnimate = index === 0 && isActive && newChatId
|
||||||
|
|
||||||
if (!chat?.id) return null
|
if (!chat?.id) return null
|
||||||
|
|
||||||
return (
|
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">
|
<div className="absolute left-2 top-1 flex h-6 w-6 items-center justify-center">
|
||||||
{chat.sharePath ? (
|
{chat.sharePath ? (
|
||||||
<Tooltip delayDuration={1000}>
|
<Tooltip delayDuration={1000}>
|
||||||
|
|
@ -45,18 +72,53 @@ export function SidebarItem({ chat, children }: SidebarItemProps) {
|
||||||
href={chat.path}
|
href={chat.path}
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant: 'ghost' }),
|
buttonVariants({ variant: 'ghost' }),
|
||||||
'group w-full pl-8 pr-16',
|
'group w-full px-8 transition-colors hover:bg-zinc-200/40 dark:hover:bg-zinc-300/10',
|
||||||
isActive && 'bg-accent'
|
isActive && 'bg-zinc-200 pr-16 font-semibold dark:bg-zinc-800'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="relative max-h-5 flex-1 select-none overflow-hidden text-ellipsis break-all"
|
className="relative max-h-5 flex-1 select-none overflow-hidden text-ellipsis break-all"
|
||||||
title={chat.title}
|
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>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
{isActive && <div className="absolute right-2 top-1">{children}</div>}
|
{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 { clearChats, getChats } from '@/app/actions'
|
||||||
import { SidebarActions } from '@/components/sidebar-actions'
|
import { ClearHistory } from '@/components/clear-history'
|
||||||
import { SidebarItem } from '@/components/sidebar-item'
|
import { SidebarItems } from '@/components/sidebar-items'
|
||||||
|
import { ThemeToggle } from '@/components/theme-toggle'
|
||||||
|
import { cache } from 'react'
|
||||||
|
|
||||||
export interface SidebarListProps {
|
interface SidebarListProps {
|
||||||
userId?: string
|
userId?: string
|
||||||
|
children?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadChats = cache(async (userId?: string) => {
|
||||||
|
return await getChats(userId)
|
||||||
|
})
|
||||||
|
|
||||||
export async function SidebarList({ userId }: SidebarListProps) {
|
export async function SidebarList({ userId }: SidebarListProps) {
|
||||||
const chats = await getChats(userId)
|
const chats = await loadChats(userId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
{chats?.length ? (
|
<div className="flex-1 overflow-auto">
|
||||||
<div className="space-y-2 px-2">
|
{chats?.length ? (
|
||||||
{chats.map(
|
<div className="space-y-2 px-2">
|
||||||
chat =>
|
<SidebarItems chats={chats} />
|
||||||
chat && (
|
</div>
|
||||||
<SidebarItem key={chat?.id} chat={chat}>
|
) : (
|
||||||
<SidebarActions
|
<div className="p-8 text-center">
|
||||||
chat={chat}
|
<p className="text-sm text-muted-foreground">No chat history</p>
|
||||||
removeChat={removeChat}
|
</div>
|
||||||
shareChat={shareChat}
|
)}
|
||||||
/>
|
</div>
|
||||||
</SidebarItem>
|
<div className="flex items-center justify-between p-4">
|
||||||
)
|
<ThemeToggle />
|
||||||
)}
|
<ClearHistory clearChats={clearChats} isEnabled={chats?.length > 0} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="p-8 text-center">
|
|
||||||
<p className="text-sm text-muted-foreground">No chat history</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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 * as React from 'react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { useSidebar } from '@/lib/hooks/use-sidebar'
|
||||||
import {
|
import { cn } from '@/lib/utils'
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
SheetTrigger
|
|
||||||
} from '@/components/ui/sheet'
|
|
||||||
import { IconSidebar } from '@/components/ui/icons'
|
|
||||||
|
|
||||||
export interface SidebarProps {
|
export interface SidebarProps extends React.ComponentProps<'div'> {}
|
||||||
children?: React.ReactNode
|
|
||||||
}
|
export function Sidebar({ className, children }: SidebarProps) {
|
||||||
|
const { isSidebarOpen, isLoading } = useSidebar()
|
||||||
|
|
||||||
export function Sidebar({ children }: SidebarProps) {
|
|
||||||
return (
|
return (
|
||||||
<Sheet>
|
<div
|
||||||
<SheetTrigger asChild>
|
data-state={isSidebarOpen && !isLoading ? 'open' : 'closed'}
|
||||||
<Button variant="ghost" className="-ml-2 h-9 w-9 p-0">
|
className={cn(className, 'h-full flex-col dark:bg-zinc-950')}
|
||||||
<IconSidebar className="h-6 w-6" />
|
>
|
||||||
<span className="sr-only">Toggle Sidebar</span>
|
{children}
|
||||||
</Button>
|
</div>
|
||||||
</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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"focus-trap-react": "^10.2.3",
|
"focus-trap-react": "^10.2.3",
|
||||||
|
"framer-motion": "^10.16.12",
|
||||||
"geist": "^1.1.0",
|
"geist": "^1.1.0",
|
||||||
"nanoid": "^5.0.3",
|
"nanoid": "^5.0.3",
|
||||||
"next": "14.0.4-canary.17",
|
"next": "14.0.4-canary.17",
|
||||||
|
|
|
||||||
34
pnpm-lock.yaml
generated
34
pnpm-lock.yaml
generated
|
|
@ -53,6 +53,9 @@ dependencies:
|
||||||
focus-trap-react:
|
focus-trap-react:
|
||||||
specifier: ^10.2.3
|
specifier: ^10.2.3
|
||||||
version: 10.2.3(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0)
|
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:
|
geist:
|
||||||
specifier: ^1.1.0
|
specifier: ^1.1.0
|
||||||
version: 1.1.0(next@14.0.4-canary.17)
|
version: 1.1.0(next@14.0.4-canary.17)
|
||||||
|
|
@ -221,6 +224,19 @@ packages:
|
||||||
to-fast-properties: 2.0.0
|
to-fast-properties: 2.0.0
|
||||||
dev: false
|
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):
|
/@eslint-community/eslint-utils@4.4.0(eslint@8.54.0):
|
||||||
resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==}
|
resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==}
|
||||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||||
|
|
@ -2618,6 +2634,24 @@ packages:
|
||||||
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
|
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
|
||||||
dev: true
|
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:
|
/fs.realpath@1.0.0:
|
||||||
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
|
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue