feat: refactor chat sharing implementation

This commit is contained in:
shadcn 2023-06-16 16:18:52 +04:00
parent 8cc3fea048
commit 137bdadaf9
12 changed files with 262 additions and 124 deletions

View file

@ -2,10 +2,9 @@
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import { kv } from '@vercel/kv' import { kv } from '@vercel/kv'
import { currentUser } from '@clerk/nextjs'
import { type Chat } from '@/lib/types' import { type Chat } from '@/lib/types'
import { currentUser } from '@clerk/nextjs'
import { nanoid } from '@/lib/utils'
export async function getChats(userId?: string | null) { export async function getChats(userId?: string | null) {
if (!userId) { if (!userId) {
@ -52,9 +51,11 @@ export async function removeChat({ id, path }: { id: string; path: string }) {
} }
const uid = await kv.hget<string>(`chat:${id}`, 'userId') const uid = await kv.hget<string>(`chat:${id}`, 'userId')
if (uid !== user.id) { if (uid !== user.id) {
throw new Error('Unauthorized') throw new Error('Unauthorized')
} }
await kv.del(`chat:${id}`) await kv.del(`chat:${id}`)
await kv.zrem(`user:chat:${user.id}`, `chat:${id}`) await kv.zrem(`user:chat:${user.id}`, `chat:${id}`)
@ -85,6 +86,16 @@ export async function clearChats() {
revalidatePath('/') 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) { export async function shareChat(chat: Chat) {
const user = await currentUser() const user = await currentUser()
@ -92,23 +103,12 @@ export async function shareChat(chat: Chat) {
throw new Error('Unauthorized') throw new Error('Unauthorized')
} }
const id = nanoid()
const createdAt = Date.now()
const payload = { const payload = {
id, ...chat,
title: chat.title, sharePath: `/share/${chat.id}`
userId: user.id,
createdAt,
path: `/share/${id}`,
chat
} }
await kv.hmset(`share:${id}`, payload) await kv.hmset(`chat:${chat.id}`, payload)
await kv.zadd(`user:share:${user.id}`, {
score: createdAt,
member: `share:${id}`
})
return payload return payload
} }

View file

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

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

@ -0,0 +1,35 @@
import { type Metadata } from 'next'
import { auth } from '@clerk/nextjs'
import { Chat } from '@/components/chat'
import { getChat, getSharedChat } from '@/app/actions'
import { notFound } from 'next/navigation'
// export const runtime = 'edge'
export const preferredRegion = 'home'
export interface SharePageProps {
params: {
id: string
}
}
export async function generateMetadata({
params
}: SharePageProps): Promise<Metadata> {
const { user } = await auth()
const chat = await getChat(params.id, user?.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 <Chat id={chat.id} initialMessages={chat.messages} />
}

View file

@ -9,7 +9,6 @@ import { Button, buttonVariants } from '@/components/ui/button'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider,
TooltipTrigger TooltipTrigger
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { IconArrowElbow, IconPlus } from '@/components/ui/icons' import { IconArrowElbow, IconPlus } from '@/components/ui/icons'
@ -47,7 +46,6 @@ export function PromptForm({
}} }}
ref={formRef} 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> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -91,7 +89,6 @@ export function PromptForm({
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
</TooltipProvider>
</form> </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

@ -4,8 +4,8 @@ import * as React from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { Share, type Chat, ServerActionResult } from '@/lib/types' import { type Chat, ServerActionResult } from '@/lib/types'
import { formatDate } from '@/lib/utils' import { cn, formatDate } from '@/lib/utils'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -25,12 +25,24 @@ import {
DialogHeader, DialogHeader,
DialogTitle DialogTitle
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { IconShare, IconSpinner, IconTrash } from '@/components/ui/icons' 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 { interface SidebarActionsProps {
chat: Chat chat: Chat
removeChat: (args: { id: string; path: string }) => Promise<void> removeChat: (args: { id: string; path: string }) => Promise<void>
shareChat: (chat: Chat) => ServerActionResult<Share> shareChat: (chat: Chat) => ServerActionResult<Chat>
} }
export function SidebarActions({ export function SidebarActions({
@ -44,59 +56,13 @@ export function SidebarActions({
const [isSharePending, startShareTransition] = React.useTransition() const [isSharePending, startShareTransition] = React.useTransition()
const router = useRouter() const router = useRouter()
return ( const copyShareLink = React.useCallback(async (chat: Chat) => {
<> if (!chat.sharePath) {
<div className="space-x-1"> return toast.error('Could not copy share link to clipboard')
<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) const url = new URL(window.location.href)
url.pathname = result.path url.pathname = chat.sharePath
navigator.clipboard.writeText(url.toString()) navigator.clipboard.writeText(url.toString())
setShareDialogOpen(false) setShareDialogOpen(false)
toast.success('Share link copied to clipboard', { toast.success('Share link copied to clipboard', {
@ -111,6 +77,85 @@ export function SidebarActions({
secondary: 'black' 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)
}) })
}} }}
> >

View file

@ -6,8 +6,12 @@ import { usePathname } from 'next/navigation'
import { type Chat } from '@/lib/types' import { type Chat } from '@/lib/types'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button' import { buttonVariants } from '@/components/ui/button'
import { IconMessage } from '@/components/ui/icons' import { IconMessage, IconUsers } from '@/components/ui/icons'
import { SidebarActions } from '@/components/sidebar-actions' import {
Tooltip,
TooltipContent,
TooltipTrigger
} from '@/components/ui/tooltip'
interface SidebarItemProps { interface SidebarItemProps {
chat: Chat chat: Chat
@ -30,7 +34,16 @@ export function SidebarItem({ chat, children }: SidebarItemProps) {
isActive && 'bg-accent' isActive && 'bg-accent'
)} )}
> >
{chat.sharePath ? (
<Tooltip>
<TooltipTrigger>
<IconUsers className="mr-2" />
</TooltipTrigger>
<TooltipContent>This is a shared chat.</TooltipContent>
</Tooltip>
) : (
<IconMessage className="mr-2" /> <IconMessage className="mr-2" />
)}
<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}

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>
}

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

@ -428,6 +428,20 @@ function IconShare({ className, ...props }: React.ComponentProps<'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 { export {
IconEdit, IconEdit,
IconNextChat, IconNextChat,
@ -452,5 +466,6 @@ export {
IconCheck, IconCheck,
IconDownload, IconDownload,
IconClose, IconClose,
IconShare IconShare,
IconUsers
} }

View file

@ -19,7 +19,7 @@ const TooltipContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( 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 className
)} )}
{...props} {...props}

View file

@ -7,15 +7,7 @@ export interface Chat extends Record<string, any> {
userId: string userId: string
path: string path: string
messages: Message[] messages: Message[]
} sharePath?: string
export interface Share {
id: string
title: string
createdAt: number
userId: string
path: string
chat: Chat
} }
export type ServerActionResult<Result> = Promise<Result | Error> export type ServerActionResult<Result> = Promise<Result | Error>