Skip to content

Commit

Permalink
feat: implement sharing
Browse files Browse the repository at this point in the history
  • Loading branch information
shadcn committed Jun 16, 2023
1 parent 385b31d commit 8cc3fea
Show file tree
Hide file tree
Showing 19 changed files with 507 additions and 96 deletions.
52 changes: 52 additions & 0 deletions app/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { kv } from '@vercel/kv'

import { type Chat } from '@/lib/types'
import { currentUser } from '@clerk/nextjs'
import { nanoid } from '@/lib/utils'

export async function getChats(userId?: string | null) {
if (!userId) {
Expand Down Expand Up @@ -60,3 +61,54 @@ export async function removeChat({ id, path }: { id: string; path: string }) {
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 shareChat(chat: Chat) {
const user = await currentUser()

if (!user || chat.userId !== user.id) {
throw new Error('Unauthorized')
}

const id = nanoid()
const createdAt = Date.now()

const payload = {
id,
title: chat.title,
userId: user.id,
createdAt,
path: `/share/${id}`,
chat
}

await kv.hmset(`share:${id}`, payload)
await kv.zadd(`user:share:${user.id}`, {
score: createdAt,
member: `share:${id}`
})

return payload
}
2 changes: 2 additions & 0 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
{
Expand Down
2 changes: 2 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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'
Expand Down Expand Up @@ -42,6 +43,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
fontMono.variable
)}
>
<Toaster />
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<div className="flex min-h-screen flex-col">
{/* @ts-ignore */}
Expand Down
3 changes: 2 additions & 1 deletion components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
65 changes: 65 additions & 0 deletions components/clear-history.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
8 changes: 8 additions & 0 deletions components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ 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'

export async function Header() {
const user = await currentUser()
Expand All @@ -19,6 +23,10 @@ export async function Header() {
{/* @ts-ignore */}
<SidebarList userId={user?.id} />
</Suspense>
<SidebarFooter>
<ThemeToggle />
<ClearHistory clearChats={clearChats} />
</SidebarFooter>
</Sidebar>
<div className="flex items-center">
<IconSeparator className="h-6 w-6 text-muted-foreground/50" />
Expand Down
164 changes: 164 additions & 0 deletions components/sidebar-actions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
'use client'

import * as React from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'react-hot-toast'

import { Share, type Chat, ServerActionResult } from '@/lib/types'
import { 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 } from '@/components/ui/icons'

interface SidebarActionsProps {
chat: Chat
removeChat: (args: { id: string; path: string }) => Promise<void>
shareChat: (chat: Chat) => ServerActionResult<Share>
}

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()

return (
<>
<div className="space-x-1">
<Button
variant="ghost"
className="h-6 w-6 p-0 hover:bg-background"
onClick={() => setShareDialogOpen(true)}
>
<IconShare />
<span className="sr-only">Share</span>
</Button>
<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>
</div>
<Dialog open={shareDialogOpen} onOpenChange={setShareDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Share link to chat</DialogTitle>
<DialogDescription>
Messages you send after creating your link won&apos;t be shared.
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)}
</div>
</div>
<DialogFooter className="items-center">
<p className="mr-auto text-sm text-muted-foreground">
Any link you have shared before will be deleted.
</p>
<Button
disabled={isSharePending}
onClick={() => {
startShareTransition(async () => {
const result = await shareChat(chat)

if (!('id' in result)) {
toast.error(result.message)
return
}

const url = new URL(window.location.href)
url.pathname = result.path
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'
}
})
})
}}
>
{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>
</>
)
}
16 changes: 16 additions & 0 deletions components/sidebar-footer.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
Loading

0 comments on commit 8cc3fea

Please sign in to comment.