feat: refactor chat sharing implementation
This commit is contained in:
parent
8cc3fea048
commit
137bdadaf9
12 changed files with 262 additions and 124 deletions
|
|
@ -2,10 +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'
|
||||
import { nanoid } from '@/lib/utils'
|
||||
|
||||
export async function getChats(userId?: string | null) {
|
||||
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')
|
||||
|
||||
if (uid !== user.id) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
await kv.del(`chat:${id}`)
|
||||
await kv.zrem(`user:chat:${user.id}`, `chat:${id}`)
|
||||
|
||||
|
|
@ -85,6 +86,16 @@ export async function clearChats() {
|
|||
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()
|
||||
|
||||
|
|
@ -92,23 +103,12 @@ export async function shareChat(chat: Chat) {
|
|||
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
|
||||
...chat,
|
||||
sharePath: `/share/${chat.id}`
|
||||
}
|
||||
|
||||
await kv.hmset(`share:${id}`, payload)
|
||||
await kv.zadd(`user:share:${user.id}`, {
|
||||
score: createdAt,
|
||||
member: `share:${id}`
|
||||
})
|
||||
await kv.hmset(`chat:${chat.id}`, payload)
|
||||
|
||||
return payload
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ 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 = {
|
||||
|
|
@ -44,14 +44,14 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
|||
)}
|
||||
>
|
||||
<Toaster />
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<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>
|
||||
</div>
|
||||
<TailwindIndicator />
|
||||
</ThemeProvider>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
</ClerkProvider>
|
||||
|
|
|
|||
35
app/share/[id]/page.tsx
Normal file
35
app/share/[id]/page.tsx
Normal 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} />
|
||||
}
|
||||
|
|
@ -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,7 +46,6 @@ 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">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -91,7 +89,6 @@ export function PromptForm({
|
|||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
14
components/providers.tsx
Normal file
14
components/providers.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,8 +4,8 @@ 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 { type Chat, ServerActionResult } from '@/lib/types'
|
||||
import { cn, formatDate } from '@/lib/utils'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -25,12 +25,24 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle
|
||||
} 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 {
|
||||
chat: Chat
|
||||
removeChat: (args: { id: string; path: string }) => Promise<void>
|
||||
shareChat: (chat: Chat) => ServerActionResult<Share>
|
||||
shareChat: (chat: Chat) => ServerActionResult<Chat>
|
||||
}
|
||||
|
||||
export function SidebarActions({
|
||||
|
|
@ -44,59 +56,13 @@ export function SidebarActions({
|
|||
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'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 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 = result.path
|
||||
url.pathname = chat.sharePath
|
||||
navigator.clipboard.writeText(url.toString())
|
||||
setShareDialogOpen(false)
|
||||
toast.success('Share link copied to clipboard', {
|
||||
|
|
@ -111,6 +77,85 @@ export function SidebarActions({
|
|||
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)
|
||||
})
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -6,8 +6,12 @@ import { usePathname } from 'next/navigation'
|
|||
import { type Chat } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
import { IconMessage } from '@/components/ui/icons'
|
||||
import { SidebarActions } from '@/components/sidebar-actions'
|
||||
import { IconMessage, IconUsers } from '@/components/ui/icons'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
interface SidebarItemProps {
|
||||
chat: Chat
|
||||
|
|
@ -30,7 +34,16 @@ export function SidebarItem({ chat, children }: SidebarItemProps) {
|
|||
isActive && 'bg-accent'
|
||||
)}
|
||||
>
|
||||
{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={chat.title}
|
||||
|
|
|
|||
|
|
@ -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
36
components/ui/badge.tsx
Normal 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 }
|
||||
|
|
@ -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 {
|
||||
IconEdit,
|
||||
IconNextChat,
|
||||
|
|
@ -452,5 +466,6 @@ export {
|
|||
IconCheck,
|
||||
IconDownload,
|
||||
IconClose,
|
||||
IconShare
|
||||
IconShare,
|
||||
IconUsers
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
10
lib/types.ts
10
lib/types.ts
|
|
@ -7,15 +7,7 @@ export interface Chat extends Record<string, any> {
|
|||
userId: string
|
||||
path: string
|
||||
messages: Message[]
|
||||
}
|
||||
|
||||
export interface Share {
|
||||
id: string
|
||||
title: string
|
||||
createdAt: number
|
||||
userId: string
|
||||
path: string
|
||||
chat: Chat
|
||||
sharePath?: string
|
||||
}
|
||||
|
||||
export type ServerActionResult<Result> = Promise<Result | Error>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue