Add sharing (#17)

This commit is contained in:
Jared Palmer 2023-06-16 10:19:05 -04:00 committed by GitHub
commit a9e4956909
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 800 additions and 207 deletions

View file

@ -2,9 +2,9 @@
import { revalidatePath } from 'next/cache'
import { kv } from '@vercel/kv'
import { currentUser } from '@clerk/nextjs'
import { type Chat } from '@/lib/types'
import { currentUser } from '@clerk/nextjs'
export async function getChats(userId?: string | null) {
if (!userId) {
@ -51,12 +51,64 @@ export async function removeChat({ id, path }: { id: string; path: string }) {
}
const uid = await kv.hget<string>(`chat:${id}`, 'userId')
if (uid !== user.id) {
throw new Error('Unauthorized')
}
await kv.del(`chat:${id}`)
await kv.zrem(`user:chat:${user.id}`, `chat:${id}`)
revalidatePath('/')
revalidatePath(path)
}
export async function clearChats() {
const user = await currentUser()
if (!user) {
throw new Error('Unauthorized')
}
const chats: string[] = await kv.zrange(`user:chat:${user.id}`, 0, -1, {
rev: true
})
const pipeline = kv.pipeline()
for (const chat of chats) {
pipeline.del(chat)
pipeline.zrem(`user:chat:${user.id}`, chat)
}
await pipeline.exec()
revalidatePath('/')
}
export async function getSharedChat(id: string) {
const chat = await kv.hgetall<Chat>(`chat:${id}`)
if (!chat || !chat.sharePath) {
return null
}
return chat
}
export async function shareChat(chat: Chat) {
const user = await currentUser()
if (!user || chat.userId !== user.id) {
throw new Error('Unauthorized')
}
const payload = {
...chat,
sharePath: `/share/${chat.id}`
}
await kv.hmset(`chat:${chat.id}`, payload)
return payload
}

View file

@ -43,11 +43,13 @@ export async function POST(req: Request) {
const userId = user.id
const id = json.id ?? nanoid()
const createdAt = Date.now()
const path = `/chat/${id}`
const payload = {
id,
title,
userId,
createdAt,
path,
messages: [
...messages,
{

View file

@ -1,12 +1,13 @@
import { Metadata } from 'next'
import { ClerkProvider } from '@clerk/nextjs'
import { Toaster } from 'react-hot-toast'
import '@/app/globals.css'
import { fontMono, fontSans } from '@/lib/fonts'
import { cn } from '@/lib/utils'
import { TailwindIndicator } from '@/components/tailwind-indicator'
import { ThemeProvider } from '@/components/theme-provider'
import { Providers } from '@/components/providers'
import { Header } from '@/components/header'
export const metadata: Metadata = {
@ -42,14 +43,17 @@ export default function RootLayout({ children }: RootLayoutProps) {
fontMono.variable
)}
>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<Toaster />
<Providers attribute="class" defaultTheme="system" enableSystem>
<div className="flex min-h-screen flex-col">
{/* @ts-ignore */}
<Header />
<main className="flex-1 bg-muted/50">{children}</main>
<main className="flex flex-1 flex-col bg-muted/50">
{children}
</main>
</div>
<TailwindIndicator />
</ThemeProvider>
</Providers>
</body>
</html>
</ClerkProvider>

53
app/share/[id]/page.tsx Normal file
View file

@ -0,0 +1,53 @@
import { type Metadata } from 'next'
import { notFound } from 'next/navigation'
import { formatDate } from '@/lib/utils'
import { getSharedChat } from '@/app/actions'
import { ChatList } from '@/components/chat-list'
import { FooterText } from '@/components/footer'
// export const runtime = 'edge'
export const preferredRegion = 'home'
export interface SharePageProps {
params: {
id: string
}
}
export async function generateMetadata({
params
}: SharePageProps): Promise<Metadata> {
const chat = await getSharedChat(params.id)
return {
title: chat?.title.slice(0, 50) ?? 'Chat'
}
}
export default async function SharePage({ params }: SharePageProps) {
const chat = await getSharedChat(params.id)
if (!chat || !chat?.sharePath) {
notFound()
}
return (
<>
<div className="flex-1 space-y-6">
<div className="border-b bg-background px-4 py-6 md:px-6 md:py-8">
<div className="mx-auto max-w-2xl md:px-6">
<div className="space-y-1">
<h1 className="text-2xl font-bold">{chat.title}</h1>
<div className="text-sm text-muted-foreground">
{formatDate(chat.createdAt)} · {chat.messages.length} messages
</div>
</div>
</div>
</div>
<ChatList messages={chat.messages} />
</div>
<FooterText className="py-8" />
</>
)
}

View file

@ -4,6 +4,7 @@ import { cn } from '@/lib/utils'
import { ExternalLink } from '@/components/external-link'
import { buttonVariants } from '@/components/ui/button'
import { IconSpinner } from '@/components/ui/icons'
import { FooterText } from '@/components/footer'
export default function SignInPage() {
return (
@ -27,14 +28,7 @@ export default function SignInPage() {
}
}}
/>
<p className="hidden px-2 text-center text-xs leading-normal text-muted-foreground sm:block">
Open source AI chatbot app built with{' '}
<ExternalLink href="https://nextjs.org">Next.js</ExternalLink> and{' '}
<ExternalLink href="https://vercel.com/storage/kv">
Vercel KV
</ExternalLink>
.
</p>
<FooterText />
</ClerkLoaded>
</div>
</div>

View file

@ -4,6 +4,7 @@ import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
import { ExternalLink } from '@/components/external-link'
import { IconSpinner } from '@/components/ui/icons'
import { FooterText } from '@/components/footer'
export default function Page() {
return (
@ -28,14 +29,7 @@ export default function Page() {
}
}}
/>
<p className="hidden px-2 text-center text-xs leading-normal text-muted-foreground sm:block">
Open source AI chatbot app built with{' '}
<ExternalLink href="https://nextjs.org">Next.js</ExternalLink> and{' '}
<ExternalLink href="https://vercel.com/storage/kv">
Vercel KV
</ExternalLink>
.
</p>
<FooterText />
</ClerkLoaded>
</div>
</div>

View file

@ -17,7 +17,9 @@ export function ChatList({ messages }: ChatList) {
{messages.map((message, index) => (
<div key={index}>
<ChatMessage message={message} />
{index < messages.length - 1 && <Separator className="my-8" />}
{index < messages.length - 1 && (
<Separator className="my-4 md:my-8" />
)}
</div>
))}
</div>

View file

@ -5,6 +5,7 @@ import { ExternalLink } from '@/components/external-link'
import { PromptForm } from '@/components/prompt-form'
import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom'
import { IconRefresh, IconStop } from '@/components/ui/icons'
import { FooterText } from '@/components/footer'
export interface ChatPanelProps
extends Pick<
@ -70,14 +71,7 @@ export function ChatPanel({
setInput={setInput}
isLoading={isLoading}
/>
<p className="hidden px-2 text-center text-xs leading-normal text-muted-foreground sm:block">
Open source AI chatbot app built with{' '}
<ExternalLink href="https://nextjs.org">Next.js</ExternalLink> and{' '}
<ExternalLink href="https://vercel.com/storage/kv">
Vercel KV
</ExternalLink>
.
</p>
<FooterText className="hidden sm:block" />
</div>
</div>
</div>

View file

@ -10,10 +10,11 @@ import { ChatScrollAnchor } from '@/components/chat-scroll-anchor'
export interface ChatProps extends React.ComponentProps<'div'> {
initialMessages?: Message[]
id?: string
}
export function Chat({ id, initialMessages, className }: ChatProps) {
export function Chat({ id, title, initialMessages, className }: ChatProps) {
const { messages, append, reload, stop, isLoading, input, setInput } =
useChat({
initialMessages,

View file

@ -0,0 +1,65 @@
'use client'
import * as React from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger
} from '@/components/ui/alert-dialog'
import { IconSpinner } from '@/components/ui/icons'
interface ClearHistoryProps {
clearChats: () => Promise<void>
}
export function ClearHistory({ clearChats }: ClearHistoryProps) {
const [open, setOpen] = React.useState(false)
const [isPending, startTransition] = React.useTransition()
const router = useRouter()
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Button variant="ghost" disabled={isPending}>
{isPending && <IconSpinner className="mr-2" />}
Clear history
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete your chat history and remove your data
from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={isPending}
onClick={event => {
event.preventDefault()
startTransition(async () => {
await clearChats()
setOpen(false)
router.push('/')
})
}}
>
{isPending && <IconSpinner className="mr-2 animate-spin" />}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

23
components/footer.tsx Normal file
View file

@ -0,0 +1,23 @@
import React from 'react'
import { ExternalLink } from '@/components/external-link'
import { cn } from '@/lib/utils'
export function FooterText({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
className={cn(
'px-2 text-center text-xs leading-normal text-muted-foreground',
className
)}
{...props}
>
Open source AI chatbot built with{' '}
<ExternalLink href="https://nextjs.org">Next.js</ExternalLink> and{' '}
<ExternalLink href="https://vercel.com/storage/kv">
Vercel KV
</ExternalLink>
.
</p>
)
}

View file

@ -1,4 +1,4 @@
import { Suspense } from 'react'
import { Suspense, use } from 'react'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
@ -6,6 +6,11 @@ import { Sidebar } from '@/components/sidebar'
import { SidebarList } from '@/components/sidebar-list'
import { IconGitHub, IconSeparator, IconVercel } from '@/components/ui/icons'
import { UserButton, currentUser } from '@clerk/nextjs'
import { SidebarFooter } from '@/components/sidebar-footer'
import { ThemeToggle } from '@/components/theme-toggle'
import { ClearHistory } from '@/components/clear-history'
import { clearChats } from '@/app/actions'
import Link from 'next/link'
export async function Header() {
const user = await currentUser()
@ -14,35 +19,54 @@ export async function Header() {
<header className="sticky top-0 z-50 flex h-16 w-full shrink-0 items-center justify-between border-b bg-gradient-to-b from-background/10 via-background/50 to-background/80 px-4 backdrop-blur-xl">
<div className="flex items-center">
{/* @ts-ignore */}
<Sidebar user={user?.id}>
<Suspense fallback={<div className="flex-1 overflow-auto" />}>
{/* @ts-ignore */}
<SidebarList userId={user?.id} />
</Suspense>
</Sidebar>
{user?.id ? (
<Sidebar>
<Suspense fallback={<div className="flex-1 overflow-auto" />}>
{/* @ts-ignore */}
<SidebarList userId={user?.id} />
</Suspense>
<SidebarFooter>
<ThemeToggle />
<ClearHistory clearChats={clearChats} />
</SidebarFooter>
</Sidebar>
) : (
<Link href="https://vercel.com" target="_blank" rel="nofollow">
<IconVercel className="mr-2 h-6 w-6" />
</Link>
)}
<div className="flex items-center">
<IconSeparator className="h-6 w-6 text-muted-foreground/50" />
<UserButton
showName
appearance={{
elements: {
afterSignOutUrl='/'
avatarBox: 'w-6 h-6 rounded-full overflow-hidden',
userButtonBox: 'flex-row-reverse',
userButtonOuterIdentifier: 'text-primary',
userButtonPopoverCard:
'shadow-lg rounded-lg p-0 border border-border w-[200px] dark:bg-zinc-950 dark:text-zinc-50',
userButtonPopoverFooter:
'p-4 border-t border-border [&>*]:dark:text-zinc-600',
userPreview: 'p-4 border-b border-border m-0',
userButtonPopoverActionButton: 'px-1 gap-1',
userButtonPopoverActionButtonText:
'text-sm tracking-normal dark:text-zinc-400',
userButtonPopoverActionButtonIcon:
'h-4 w-4 text-muted-foreground'
}
}}
/>
{user?.id ? (
<UserButton
showName
appearance={{
elements: {
afterSignOutUrl: '/',
avatarBox: 'w-6 h-6 rounded-full overflow-hidden',
userButtonBox: 'flex-row-reverse',
userButtonOuterIdentifier: 'text-primary',
userButtonPopoverCard:
'shadow-lg rounded-lg p-0 border border-border w-[200px] dark:bg-zinc-950 dark:text-zinc-50',
userButtonPopoverFooter:
'p-4 border-t border-border [&>*]:dark:text-zinc-600',
userPreview: 'p-4 border-b border-border m-0',
userButtonPopoverActionButton: 'px-1 gap-1',
userButtonPopoverActionButtonText:
'text-sm tracking-normal dark:text-zinc-400',
userButtonPopoverActionButtonIcon:
'h-4 w-4 text-muted-foreground'
}
}}
/>
) : (
<Link
href="/sign-in"
className={cn(buttonVariants({ variant: 'ghost' }))}
>
Sign in
</Link>
)}
</div>
</div>
<div className="flex items-center justify-end space-x-2">

View file

@ -9,7 +9,6 @@ import { Button, buttonVariants } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip'
import { IconArrowElbow, IconPlus } from '@/components/ui/icons'
@ -47,51 +46,49 @@ export function PromptForm({
}}
ref={formRef}
>
<TooltipProvider>
<div className="relative flex w-full grow flex-col overflow-hidden bg-background px-8 sm:rounded-md sm:border sm:px-12">
<div className="relative flex w-full grow flex-col overflow-hidden bg-background px-8 sm:rounded-md sm:border sm:px-12">
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/"
className={cn(
buttonVariants({ size: 'sm', variant: 'outline' }),
'absolute left-0 top-4 h-8 w-8 rounded-full bg-background p-0 sm:left-4'
)}
>
<IconPlus />
<span className="sr-only">New Chat</span>
</Link>
</TooltipTrigger>
<TooltipContent>New Chat</TooltipContent>
</Tooltip>
<Textarea
ref={inputRef}
tabIndex={0}
onKeyDown={onKeyDown}
rows={1}
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Send a message."
spellCheck={false}
className="min-h-[60px] w-full resize-none bg-transparent px-4 py-[1.3rem] focus-within:outline-none sm:text-sm"
/>
<div className="absolute right-0 top-4 sm:right-4">
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/"
className={cn(
buttonVariants({ size: 'sm', variant: 'outline' }),
'absolute left-0 top-4 h-8 w-8 rounded-full bg-background p-0 sm:left-4'
)}
<Button
type="submit"
size="icon"
disabled={isLoading || input === ''}
>
<IconPlus />
<span className="sr-only">New Chat</span>
</Link>
<IconArrowElbow />
<span className="sr-only">Send message</span>
</Button>
</TooltipTrigger>
<TooltipContent>New Chat</TooltipContent>
<TooltipContent>Send message</TooltipContent>
</Tooltip>
<Textarea
ref={inputRef}
tabIndex={0}
onKeyDown={onKeyDown}
rows={1}
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Send a message."
spellCheck={false}
className="min-h-[60px] w-full resize-none bg-transparent px-4 py-[1.3rem] focus-within:outline-none sm:text-sm"
/>
<div className="absolute right-0 top-4 sm:right-4">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="submit"
size="icon"
disabled={isLoading || input === ''}
>
<IconArrowElbow />
<span className="sr-only">Send message</span>
</Button>
</TooltipTrigger>
<TooltipContent>Send message</TooltipContent>
</Tooltip>
</div>
</div>
</TooltipProvider>
</div>
</form>
)
}

14
components/providers.tsx Normal file
View file

@ -0,0 +1,14 @@
'use client'
import * as React from 'react'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { ThemeProviderProps } from 'next-themes/dist/types'
import { TooltipProvider } from '@/components/ui/tooltip'
export function Providers({ children, ...props }: ThemeProviderProps) {
return (
<NextThemesProvider {...props}>
<TooltipProvider>{children}</TooltipProvider>
</NextThemesProvider>
)
}

View file

@ -0,0 +1,209 @@
'use client'
import * as React from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'react-hot-toast'
import { type Chat, ServerActionResult } from '@/lib/types'
import { cn, formatDate } from '@/lib/utils'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
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 {
Tooltip,
TooltipContent,
TooltipTrigger
} from '@/components/ui/tooltip'
interface SidebarActionsProps {
chat: Chat
removeChat: (args: { id: string; path: string }) => Promise<void>
shareChat: (chat: Chat) => ServerActionResult<Chat>
}
export function SidebarActions({
chat,
removeChat,
shareChat
}: SidebarActionsProps) {
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 (
<>
<div className="space-x-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
className="h-6 w-6 p-0 hover:bg-background"
onClick={() => setShareDialogOpen(true)}
>
<IconShare />
<span className="sr-only">Share</span>
</Button>
</TooltipTrigger>
<TooltipContent>Share chat</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
className="h-6 w-6 p-0 hover:bg-background"
disabled={isRemovePending}
onClick={() => setDeleteDialogOpen(true)}
>
<IconTrash />
<span className="sr-only">Delete</span>
</Button>
</TooltipTrigger>
<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(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 (!('id' in result)) {
toast.error(result.message)
return
}
copyShareLink(result)
})
}}
>
{isSharePending ? (
<>
<IconSpinner className="mr-2 animate-spin" />
Copying...
</>
) : (
<>Copy link</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete your chat message and remove your
data from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isRemovePending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={isRemovePending}
onClick={event => {
event.preventDefault()
startRemoveTransition(async () => {
await removeChat({
id: chat.id,
path: chat.path
})
setDeleteDialogOpen(false)
router.push('/')
})
}}
>
{isRemovePending && <IconSpinner className="mr-2 animate-spin" />}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View file

@ -0,0 +1,16 @@
import { cn } from '@/lib/utils'
export function SidebarFooter({
children,
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
className={cn('flex items-center justify-between p-4', className)}
{...props}
>
{children}
</div>
)
}

View file

@ -1,97 +1,57 @@
'use client'
import * as React from 'react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { usePathname } from 'next/navigation'
import { type Chat } from '@/lib/types'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
import { IconMessage, IconUsers } from '@/components/ui/icons'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Button, buttonVariants } from '@/components/ui/button'
import { IconMessage, IconSpinner, IconTrash } from '@/components/ui/icons'
Tooltip,
TooltipContent,
TooltipTrigger
} from '@/components/ui/tooltip'
interface SidebarItemProps {
title: string
href: string
id: string
removeChat: (args: { id: string; path: string }) => Promise<void>
chat: Chat
children: React.ReactNode
}
export function SidebarItem({ title, href, id, removeChat }: SidebarItemProps) {
const [open, setIsOpen] = React.useState(false)
export function SidebarItem({ chat, children }: SidebarItemProps) {
const pathname = usePathname()
const router = useRouter()
const [isPending, startTransition] = React.useTransition()
const isActive = pathname === href
const isActive = pathname === chat.path
if (!id) return null
if (!chat?.id) return null
return (
<>
<Link
href={href}
href={chat.path}
className={cn(
buttonVariants({ variant: 'ghost' }),
'group w-full px-2',
isActive && 'bg-accent'
)}
>
<IconMessage className="mr-2" />
{chat.sharePath ? (
<Tooltip>
<TooltipTrigger>
<IconUsers className="mr-2" />
</TooltipTrigger>
<TooltipContent>This is a shared chat.</TooltipContent>
</Tooltip>
) : (
<IconMessage className="mr-2" />
)}
<div
className="relative max-h-5 flex-1 select-none overflow-hidden text-ellipsis break-all"
title={title}
title={chat.title}
>
<span className="whitespace-nowrap">{title}</span>
<span className="whitespace-nowrap">{chat.title}</span>
</div>
{isActive && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={isPending}
onClick={() => setIsOpen(true)}
>
<IconTrash />
<span className="sr-only">Delete</span>
</Button>
)}
{isActive && children}
</Link>
<AlertDialog open={open} onOpenChange={setIsOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete your chat message and remove your
data from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={isPending}
onClick={event => {
event.preventDefault()
startTransition(async () => {
await removeChat({ id, path: href })
setIsOpen(false)
router.push('/')
})
}}
>
{isPending && <IconSpinner className="mr-2 animate-spin" />}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View file

@ -1,4 +1,5 @@
import { getChats, removeChat } from '@/app/actions'
import { getChats, removeChat, shareChat } from '@/app/actions'
import { SidebarActions } from '@/components/sidebar-actions'
import { SidebarItem } from '@/components/sidebar-item'
@ -9,19 +10,24 @@ export interface SidebarListProps {
export async function SidebarList({ userId }: SidebarListProps) {
const chats = await getChats(userId)
console.log(chats)
return (
<div className="flex-1 overflow-auto">
{chats?.length ? (
<div className="space-y-2 px-2">
{chats.map(chat => (
<SidebarItem
key={chat.id}
title={chat.title}
href={`/chat/${chat.id}`}
id={chat.id}
removeChat={removeChat}
/>
))}
{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">

View file

@ -10,18 +10,13 @@ import {
SheetTitle,
SheetTrigger
} from '@/components/ui/sheet'
import { ThemeToggle } from '@/components/theme-toggle'
import { IconSidebar } from '@/components/ui/icons'
import { useClerk } from '@clerk/nextjs'
export interface SidebarProps {
userId?: string
children?: React.ReactNode
}
export function Sidebar({ userId, children }: SidebarProps) {
const { signOut } = useClerk()
export function Sidebar({ children }: SidebarProps) {
return (
<Sheet>
<SheetTrigger asChild>
@ -35,18 +30,6 @@ export function Sidebar({ userId, children }: SidebarProps) {
<SheetTitle className="text-sm">Chat History</SheetTitle>
</SheetHeader>
{children}
<div className="flex items-center p-4">
<ThemeToggle />
{userId && (
<Button
variant="ghost"
onClick={() => signOut()}
className="ml-auto"
>
Logout
</Button>
)}
</div>
</SheetContent>
</Sheet>
)

View file

@ -1,9 +0,0 @@
'use client'
import * as React from 'react'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { ThemeProviderProps } from 'next-themes/dist/types'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

3
components/toaster.tsx Normal file
View file

@ -0,0 +1,3 @@
'use client'
export { Toaster } from 'react-hot-toast'

36
components/ui/badge.tsx Normal file
View file

@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center border rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"bg-primary hover:bg-primary/80 border-transparent text-primary-foreground",
secondary:
"bg-secondary hover:bg-secondary/80 border-transparent text-secondary-foreground",
destructive:
"bg-destructive hover:bg-destructive/80 border-transparent text-destructive-foreground",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View file

@ -414,6 +414,34 @@ function IconEdit({ className, ...props }: React.ComponentProps<'svg'>) {
)
}
function IconShare({ className, ...props }: React.ComponentProps<'svg'>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
className={cn('h-4 w-4', className)}
viewBox="0 0 256 256"
{...props}
>
<path d="m237.66 106.35-80-80A8 8 0 0 0 144 32v40.35c-25.94 2.22-54.59 14.92-78.16 34.91-28.38 24.08-46.05 55.11-49.76 87.37a12 12 0 0 0 20.68 9.58c11-11.71 50.14-48.74 107.24-52V192a8 8 0 0 0 13.66 5.65l80-80a8 8 0 0 0 0-11.3ZM160 172.69V144a8 8 0 0 0-8-8c-28.08 0-55.43 7.33-81.29 21.8a196.17 196.17 0 0 0-36.57 26.52c5.8-23.84 20.42-46.51 42.05-64.86C99.41 99.77 127.75 88 152 88a8 8 0 0 0 8-8V51.32L220.69 112Z" />
</svg>
)
}
function IconUsers({ className, ...props }: React.ComponentProps<'svg'>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
className={cn('h-4 w-4', className)}
viewBox="0 0 256 256"
{...props}
>
<path d="M117.25 157.92a60 60 0 1 0-66.5 0 95.83 95.83 0 0 0-47.22 37.71 8 8 0 1 0 13.4 8.74 80 80 0 0 1 134.14 0 8 8 0 0 0 13.4-8.74 95.83 95.83 0 0 0-47.22-37.71ZM40 108a44 44 0 1 1 44 44 44.05 44.05 0 0 1-44-44Zm210.14 98.7a8 8 0 0 1-11.07-2.33A79.83 79.83 0 0 0 172 168a8 8 0 0 1 0-16 44 44 0 1 0-16.34-84.87 8 8 0 1 1-5.94-14.85 60 60 0 0 1 55.53 105.64 95.83 95.83 0 0 1 47.22 37.71 8 8 0 0 1-2.33 11.07Z" />
</svg>
)
}
export {
IconEdit,
IconNextChat,
@ -437,5 +465,7 @@ export {
IconCopy,
IconCheck,
IconDownload,
IconClose
IconClose,
IconShare,
IconUsers
}

26
components/ui/label.tsx Normal file
View file

@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

29
components/ui/switch.tsx Normal file
View file

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View file

@ -19,7 +19,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm font-medium text-popover-foreground shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1',
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-xs font-medium text-popover-foreground shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1',
className
)}
{...props}

View file

@ -5,5 +5,9 @@ export interface Chat extends Record<string, any> {
title: string
createdAt: Date
userId: string
path: string
messages: Message[]
sharePath?: string
}
export type ServerActionResult<Result> = Promise<Result | Error>

View file

@ -32,3 +32,12 @@ export async function fetcher<JSON = any>(
return res.json()
}
export function formatDate(input: string | number | Date): string {
const date = new Date(input)
return date.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
})
}

View file

@ -1,7 +1,9 @@
import { authMiddleware } from '@clerk/nextjs'
// @see https://clerk.dev
export default authMiddleware()
export default authMiddleware({
publicRoutes: ['/share/:id']
})
export const config = {
matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)']

View file

@ -17,8 +17,10 @@
"@clerk/nextjs": "^4.21.1",
"@radix-ui/react-alert-dialog": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.6",
"@vercel/analytics": "^1.0.0",
"@vercel/kv": "^0.2.1",

68
pnpm-lock.yaml generated
View file

@ -17,12 +17,18 @@ dependencies:
'@radix-ui/react-dialog':
specifier: ^1.0.4
version: 1.0.4(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-label':
specifier: ^2.0.2
version: 2.0.2(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-separator':
specifier: ^1.0.3
version: 1.0.3(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-slot':
specifier: ^1.0.2
version: 1.0.2(@types/react@18.2.6)(react@18.2.0)
'@radix-ui/react-switch':
specifier: ^1.0.3
version: 1.0.3(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-tooltip':
specifier: ^1.0.6
version: 1.0.6(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
@ -709,6 +715,27 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-label@2.0.2(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-N5ehvlM7qoTLx7nWPodsPYPgMzA5WM8zZChQg8nyFJKnDO5WHdba1vv5/H6IO5LtJMfD2Q3wh1qHFGNtK0w3bQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.21.5
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
'@types/react': 18.2.6
'@types/react-dom': 18.2.4
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-popper@1.1.2(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-1CnGGfFi/bbqtJZZ0P/NQY20xdG3E0LALJaLUEoKwPLwl6PPPfbeiCqMVQnhoFRAxjJj4RpBRJzDmUgsex2tSg==}
peerDependencies:
@ -839,6 +866,33 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-switch@1.0.3(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.21.5
'@radix-ui/primitive': 1.0.1
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.6)(react@18.2.0)
'@radix-ui/react-context': 1.0.1(@types/react@18.2.6)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.6)(react@18.2.0)
'@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.6)(react@18.2.0)
'@radix-ui/react-use-size': 1.0.1(@types/react@18.2.6)(react@18.2.0)
'@types/react': 18.2.6
'@types/react-dom': 18.2.4
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-tooltip@1.0.6(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-DmNFOiwEc2UDigsYj6clJENma58OelxD24O4IODoZ+3sQc3Zb+L8w1EP+y9laTuKCLAysPw4fD6/v0j4KNV8rg==}
peerDependencies:
@ -929,6 +983,20 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-use-previous@1.0.1(@types/react@18.2.6)(react@18.2.0):
resolution: {integrity: sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@babel/runtime': 7.21.5
'@types/react': 18.2.6
react: 18.2.0
dev: false
/@radix-ui/react-use-rect@1.0.1(@types/react@18.2.6)(react@18.2.0):
resolution: {integrity: sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==}
peerDependencies:

View file

@ -87,7 +87,7 @@ module.exports = {
},
animation: {
'slide-from-left':
'slide-from-left 0.4s cubic-bezier(0.82, 0.085, 0.395, 0.895)',
'slide-from-left 0.3s cubic-bezier(0.82, 0.085, 0.395, 0.895)',
'slide-to-left':
'slide-to-left 0.25s cubic-bezier(0.82, 0.085, 0.395, 0.895)',
'accordion-down': 'accordion-down 0.2s ease-out',