From be90a40427816416bd15b620c8efda307f780f44 Mon Sep 17 00:00:00 2001 From: Jared Palmer Date: Mon, 4 Dec 2023 12:42:53 -0500 Subject: [PATCH] Improve sidebar, new chat, and share dialog (#190) --- app/{ => (chat)}/chat/[id]/page.tsx | 0 app/(chat)/layout.tsx | 17 ++++ app/{ => (chat)}/page.tsx | 0 app/actions.ts | 14 +++- app/layout.tsx | 8 +- components/chat-history.tsx | 46 ++++++++++ components/chat-panel.tsx | 53 +++++++++--- components/chat-share-dialog.tsx | 109 ++++++++++++++++++++++++ components/clear-history.tsx | 26 +++--- components/header.tsx | 18 ++-- components/prompt-form.tsx | 14 ++-- components/providers.tsx | 6 +- components/sidebar-actions.tsx | 125 ++++------------------------ components/sidebar-desktop.tsx | 19 +++++ components/sidebar-item.tsx | 78 +++++++++++++++-- components/sidebar-items.tsx | 42 ++++++++++ components/sidebar-list.tsx | 54 ++++++------ components/sidebar-mobile.tsx | 28 +++++++ components/sidebar-toggle.tsx | 24 ++++++ components/sidebar.tsx | 39 +++------ lib/hooks/use-sidebar.tsx | 60 +++++++++++++ package.json | 1 + pnpm-lock.yaml | 34 ++++++++ 23 files changed, 598 insertions(+), 217 deletions(-) rename app/{ => (chat)}/chat/[id]/page.tsx (100%) create mode 100644 app/(chat)/layout.tsx rename app/{ => (chat)}/page.tsx (100%) create mode 100644 components/chat-history.tsx create mode 100644 components/chat-share-dialog.tsx create mode 100644 components/sidebar-desktop.tsx create mode 100644 components/sidebar-items.tsx create mode 100644 components/sidebar-mobile.tsx create mode 100644 components/sidebar-toggle.tsx create mode 100644 lib/hooks/use-sidebar.tsx diff --git a/app/chat/[id]/page.tsx b/app/(chat)/chat/[id]/page.tsx similarity index 100% rename from app/chat/[id]/page.tsx rename to app/(chat)/chat/[id]/page.tsx diff --git a/app/(chat)/layout.tsx b/app/(chat)/layout.tsx new file mode 100644 index 000000000..a19df3f25 --- /dev/null +++ b/app/(chat)/layout.tsx @@ -0,0 +1,17 @@ +import { SidebarDesktop } from '@/components/sidebar-desktop' + +interface ChatLayoutProps { + children: React.ReactNode +} + +export default async function ChatLayout({ children }: ChatLayoutProps) { + return ( +
+ {/* @ts-ignore */} + +
+ {children} +
+
+ ) +} diff --git a/app/page.tsx b/app/(chat)/page.tsx similarity index 100% rename from app/page.tsx rename to app/(chat)/page.tsx diff --git a/app/actions.ts b/app/actions.ts index 2c8a5ddf9..312312ec6 100644 --- a/app/actions.ts +++ b/app/actions.ts @@ -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:${id}`) + + if (!chat || chat.userId !== session.user.id) { + return { + error: 'Something went wrong' + } + } + const payload = { ...chat, sharePath: `/share/${chat.id}` diff --git a/app/layout.tsx b/app/layout.tsx index 25b5559bf..9a1d2da5b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -44,8 +44,14 @@ export default function RootLayout({ children }: RootLayoutProps) { )} > - +
+ {/* @ts-ignore */}
{children}
diff --git a/components/chat-history.tsx b/components/chat-history.tsx new file mode 100644 index 000000000..7450fe8cb --- /dev/null +++ b/components/chat-history.tsx @@ -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 ( +
+
+ + + New Chat + +
+ + {Array.from({ length: 10 }).map((_, i) => ( +
+ ))} +
+ } + > + {/* @ts-ignore */} + +
+
+ ) +} diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index 13e3c2fef..c92844a9f 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -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 ( -
+
-
+
{isLoading ? ( + messages?.length >= 2 && ( +
+ + {id && title ? ( + <> + + setShareDialogOpen(false)} + shareChat={shareChat} + chat={{ + id, + title, + messages + }} + /> + + ) : null} +
) )}
-
+
{ await append({ diff --git a/components/chat-share-dialog.tsx b/components/chat-share-dialog.tsx new file mode 100644 index 000000000..c5913f693 --- /dev/null +++ b/components/chat-share-dialog.tsx @@ -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 + shareChat: (id: string) => ServerActionResult + 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 ( + + + + Share link to chat + + Anyone with the URL will be able to view the shared chat. + + +
+
{chat.title}
+
+ {chat.messages.length} messages +
+
+ + + +
+
+ ) +} diff --git a/components/clear-history.tsx b/components/clear-history.tsx index 16e364349..553d2db3c 100644 --- a/components/clear-history.tsx +++ b/components/clear-history.tsx @@ -20,10 +20,14 @@ import { import { IconSpinner } from '@/components/ui/icons' interface ClearHistoryProps { + isEnabled: boolean clearChats: () => ServerActionResult } -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 ( - @@ -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('/') + }) }) }} > diff --git a/components/header.tsx b/components/header.tsx index 23677f790..aa6037295 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -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 ? ( - - }> - - - - - - - + <> + + + + + ) : ( diff --git a/components/prompt-form.tsx b/components/prompt-form.tsx index a766ad2de..fdb7c7b73 100644 --- a/components/prompt-form.tsx +++ b/components/prompt-form.tsx @@ -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 { - onSubmit: (value: string) => Promise + onSubmit: (value: string) => void isLoading: boolean } @@ -28,7 +27,6 @@ export function PromptForm({ const { formRef, onKeyDown } = useEnterSubmit() const inputRef = React.useRef(null) const router = useRouter() - React.useEffect(() => { if (inputRef.current) { inputRef.current.focus() @@ -47,7 +45,7 @@ export function PromptForm({ }} ref={formRef} > -
+
- - - - Share link to chat - - Anyone with the URL will be able to view the shared chat. - - -
-
{chat.title}
-
- {formatDate(Number(chat.createdAt))} ยท {chat.messages.length}{' '} - messages -
-
- - {chat.sharePath && ( - - - {chat.sharePath} - - )} - - -
-
+ setShareDialogOpen(false)} + /> @@ -189,6 +95,7 @@ export function SidebarActions({ disabled={isRemovePending} onClick={event => { event.preventDefault() + // @ts-ignore startRemoveTransition(async () => { const result = await removeChat({ id: chat.id, diff --git a/components/sidebar-desktop.tsx b/components/sidebar-desktop.tsx new file mode 100644 index 000000000..7bc0e19c1 --- /dev/null +++ b/components/sidebar-desktop.tsx @@ -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 ( + + {/* @ts-ignore */} + + + ) +} diff --git a/components/sidebar-item.tsx b/components/sidebar-item.tsx index a58743cc4..a4e40443b 100644 --- a/components/sidebar-item.tsx +++ b/components/sidebar-item.tsx @@ -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 ( -
+
{chat.sharePath ? ( @@ -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' )} >
- {chat.title} + + {shouldAnimate ? ( + chat.title.split('').map((character, index) => ( + { + if (index === chat.title.length - 1) { + setNewChatId(null) + } + }} + > + {character} + + )) + ) : ( + {chat.title} + )} +
{isActive &&
{children}
} -
+
) } diff --git a/components/sidebar-items.tsx b/components/sidebar-items.tsx new file mode 100644 index 000000000..11cc7fc47 --- /dev/null +++ b/components/sidebar-items.tsx @@ -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 ( + + {chats.map( + (chat, index) => + chat && ( + + + + + + ) + )} + + ) +} diff --git a/components/sidebar-list.tsx b/components/sidebar-list.tsx index de2049abf..0a7fb1ade 100644 --- a/components/sidebar-list.tsx +++ b/components/sidebar-list.tsx @@ -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 ( -
- {chats?.length ? ( -
- {chats.map( - chat => - chat && ( - - - - ) - )} -
- ) : ( -
-

No chat history

-
- )} +
+
+ {chats?.length ? ( +
+ +
+ ) : ( +
+

No chat history

+
+ )} +
+
+ + 0} /> +
) } diff --git a/components/sidebar-mobile.tsx b/components/sidebar-mobile.tsx new file mode 100644 index 000000000..50adcb17b --- /dev/null +++ b/components/sidebar-mobile.tsx @@ -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 ( + + + + + + {children} + + + ) +} diff --git a/components/sidebar-toggle.tsx b/components/sidebar-toggle.tsx new file mode 100644 index 000000000..951d60657 --- /dev/null +++ b/components/sidebar-toggle.tsx @@ -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 ( + + ) +} diff --git a/components/sidebar.tsx b/components/sidebar.tsx index e842b12e4..52dc5bd76 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -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 ( - - - - - - - Chat History - - {children} - - +
+ {children} +
) } diff --git a/lib/hooks/use-sidebar.tsx b/lib/hooks/use-sidebar.tsx new file mode 100644 index 000000000..4a52baf19 --- /dev/null +++ b/lib/hooks/use-sidebar.tsx @@ -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( + 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 ( + + {children} + + ) +} diff --git a/package.json b/package.json index bd1f9be9c..2166b7dec 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 136bd8623..a41c2e9c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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