feat: implement sharing
This commit is contained in:
parent
385b31d42c
commit
8cc3fea048
19 changed files with 507 additions and 96 deletions
|
|
@ -5,6 +5,7 @@ import { kv } from '@vercel/kv'
|
||||||
|
|
||||||
import { type Chat } from '@/lib/types'
|
import { type Chat } from '@/lib/types'
|
||||||
import { currentUser } from '@clerk/nextjs'
|
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) {
|
||||||
|
|
@ -60,3 +61,54 @@ export async function removeChat({ id, path }: { id: string; path: string }) {
|
||||||
revalidatePath('/')
|
revalidatePath('/')
|
||||||
revalidatePath(path)
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,13 @@ export async function POST(req: Request) {
|
||||||
const userId = user.id
|
const userId = user.id
|
||||||
const id = json.id ?? nanoid()
|
const id = json.id ?? nanoid()
|
||||||
const createdAt = Date.now()
|
const createdAt = Date.now()
|
||||||
|
const path = `/chat/${id}`
|
||||||
const payload = {
|
const payload = {
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
userId,
|
userId,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
path,
|
||||||
messages: [
|
messages: [
|
||||||
...messages,
|
...messages,
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Metadata } from 'next'
|
import { Metadata } from 'next'
|
||||||
|
|
||||||
import { ClerkProvider } from '@clerk/nextjs'
|
import { ClerkProvider } from '@clerk/nextjs'
|
||||||
|
import { Toaster } from 'react-hot-toast'
|
||||||
|
|
||||||
import '@/app/globals.css'
|
import '@/app/globals.css'
|
||||||
import { fontMono, fontSans } from '@/lib/fonts'
|
import { fontMono, fontSans } from '@/lib/fonts'
|
||||||
|
|
@ -42,6 +43,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
||||||
fontMono.variable
|
fontMono.variable
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<Toaster />
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ThemeProvider 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 */}
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,11 @@ import { ChatScrollAnchor } from '@/components/chat-scroll-anchor'
|
||||||
|
|
||||||
export interface ChatProps extends React.ComponentProps<'div'> {
|
export interface ChatProps extends React.ComponentProps<'div'> {
|
||||||
initialMessages?: Message[]
|
initialMessages?: Message[]
|
||||||
|
|
||||||
id?: string
|
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 } =
|
const { messages, append, reload, stop, isLoading, input, setInput } =
|
||||||
useChat({
|
useChat({
|
||||||
initialMessages,
|
initialMessages,
|
||||||
|
|
|
||||||
65
components/clear-history.tsx
Normal file
65
components/clear-history.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,10 @@ import { Sidebar } from '@/components/sidebar'
|
||||||
import { SidebarList } from '@/components/sidebar-list'
|
import { SidebarList } from '@/components/sidebar-list'
|
||||||
import { IconGitHub, IconSeparator, IconVercel } from '@/components/ui/icons'
|
import { IconGitHub, IconSeparator, IconVercel } from '@/components/ui/icons'
|
||||||
import { UserButton, currentUser } from '@clerk/nextjs'
|
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() {
|
export async function Header() {
|
||||||
const user = await currentUser()
|
const user = await currentUser()
|
||||||
|
|
@ -19,6 +23,10 @@ export async function Header() {
|
||||||
{/* @ts-ignore */}
|
{/* @ts-ignore */}
|
||||||
<SidebarList userId={user?.id} />
|
<SidebarList userId={user?.id} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
<SidebarFooter>
|
||||||
|
<ThemeToggle />
|
||||||
|
<ClearHistory clearChats={clearChats} />
|
||||||
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<IconSeparator className="h-6 w-6 text-muted-foreground/50" />
|
<IconSeparator className="h-6 w-6 text-muted-foreground/50" />
|
||||||
|
|
|
||||||
164
components/sidebar-actions.tsx
Normal file
164
components/sidebar-actions.tsx
Normal file
|
|
@ -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'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
components/sidebar-footer.tsx
Normal file
16
components/sidebar-footer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,43 +1,29 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
|
||||||
import Link from 'next/link'
|
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 { cn } from '@/lib/utils'
|
||||||
import {
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
AlertDialog,
|
import { IconMessage } from '@/components/ui/icons'
|
||||||
AlertDialogAction,
|
import { SidebarActions } from '@/components/sidebar-actions'
|
||||||
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'
|
|
||||||
|
|
||||||
interface SidebarItemProps {
|
interface SidebarItemProps {
|
||||||
title: string
|
chat: Chat
|
||||||
href: string
|
children: React.ReactNode
|
||||||
id: string
|
|
||||||
removeChat: (args: { id: string; path: string }) => Promise<void>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarItem({ title, href, id, removeChat }: SidebarItemProps) {
|
export function SidebarItem({ chat, children }: SidebarItemProps) {
|
||||||
const [open, setIsOpen] = React.useState(false)
|
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const router = useRouter()
|
const isActive = pathname === chat.path
|
||||||
const [isPending, startTransition] = React.useTransition()
|
|
||||||
const isActive = pathname === href
|
|
||||||
|
|
||||||
if (!id) return null
|
if (!chat?.id) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={chat.path || `/chat/${chat.id}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant: 'ghost' }),
|
buttonVariants({ variant: 'ghost' }),
|
||||||
'group w-full px-2',
|
'group w-full px-2',
|
||||||
|
|
@ -47,51 +33,12 @@ export function SidebarItem({ title, href, id, removeChat }: SidebarItemProps) {
|
||||||
<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={title}
|
title={chat.title}
|
||||||
>
|
>
|
||||||
<span className="whitespace-nowrap">{title}</span>
|
<span className="whitespace-nowrap">{chat.title}</span>
|
||||||
</div>
|
</div>
|
||||||
{isActive && (
|
{children}
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
disabled={isPending}
|
|
||||||
onClick={() => setIsOpen(true)}
|
|
||||||
>
|
|
||||||
<IconTrash />
|
|
||||||
<span className="sr-only">Delete</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Link>
|
</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>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'
|
import { SidebarItem } from '@/components/sidebar-item'
|
||||||
|
|
||||||
|
|
@ -9,19 +10,24 @@ export interface SidebarListProps {
|
||||||
export async function SidebarList({ userId }: SidebarListProps) {
|
export async function SidebarList({ userId }: SidebarListProps) {
|
||||||
const chats = await getChats(userId)
|
const chats = await getChats(userId)
|
||||||
|
|
||||||
|
console.log(chats)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{chats?.length ? (
|
{chats?.length ? (
|
||||||
<div className="space-y-2 px-2">
|
<div className="space-y-2 px-2">
|
||||||
{chats.map(chat => (
|
{chats.map(
|
||||||
<SidebarItem
|
chat =>
|
||||||
key={chat.id}
|
chat && (
|
||||||
title={chat.title}
|
<SidebarItem key={chat?.id} chat={chat}>
|
||||||
href={`/chat/${chat.id}`}
|
<SidebarActions
|
||||||
id={chat.id}
|
chat={chat}
|
||||||
removeChat={removeChat}
|
removeChat={removeChat}
|
||||||
/>
|
shareChat={shareChat}
|
||||||
))}
|
/>
|
||||||
|
</SidebarItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
|
|
|
||||||
|
|
@ -10,18 +10,14 @@ import {
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetTrigger
|
SheetTrigger
|
||||||
} from '@/components/ui/sheet'
|
} from '@/components/ui/sheet'
|
||||||
import { ThemeToggle } from '@/components/theme-toggle'
|
|
||||||
import { IconSidebar } from '@/components/ui/icons'
|
import { IconSidebar } from '@/components/ui/icons'
|
||||||
import { useClerk } from '@clerk/nextjs'
|
|
||||||
|
|
||||||
export interface SidebarProps {
|
export interface SidebarProps {
|
||||||
userId?: string
|
userId?: string
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ userId, children }: SidebarProps) {
|
export function Sidebar({ children }: SidebarProps) {
|
||||||
const { signOut } = useClerk()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet>
|
<Sheet>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
|
|
@ -35,18 +31,6 @@ export function Sidebar({ userId, children }: SidebarProps) {
|
||||||
<SheetTitle className="text-sm">Chat History</SheetTitle>
|
<SheetTitle className="text-sm">Chat History</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
{children}
|
{children}
|
||||||
<div className="flex items-center p-4">
|
|
||||||
<ThemeToggle />
|
|
||||||
{userId && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => signOut()}
|
|
||||||
className="ml-auto"
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
3
components/toaster.tsx
Normal file
3
components/toaster.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
export { Toaster } from 'react-hot-toast'
|
||||||
|
|
@ -414,6 +414,20 @@ 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
IconEdit,
|
IconEdit,
|
||||||
IconNextChat,
|
IconNextChat,
|
||||||
|
|
@ -437,5 +451,6 @@ export {
|
||||||
IconCopy,
|
IconCopy,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconClose
|
IconClose,
|
||||||
|
IconShare
|
||||||
}
|
}
|
||||||
|
|
|
||||||
26
components/ui/label.tsx
Normal file
26
components/ui/label.tsx
Normal 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
29
components/ui/switch.tsx
Normal 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 }
|
||||||
12
lib/types.ts
12
lib/types.ts
|
|
@ -5,5 +5,17 @@ export interface Chat extends Record<string, any> {
|
||||||
title: string
|
title: string
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
userId: string
|
userId: string
|
||||||
|
path: string
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Share {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
createdAt: number
|
||||||
|
userId: string
|
||||||
|
path: string
|
||||||
|
chat: Chat
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerActionResult<Result> = Promise<Result | Error>
|
||||||
|
|
|
||||||
|
|
@ -32,3 +32,12 @@ export async function fetcher<JSON = any>(
|
||||||
|
|
||||||
return res.json()
|
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'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,10 @@
|
||||||
"@clerk/nextjs": "^4.21.1",
|
"@clerk/nextjs": "^4.21.1",
|
||||||
"@radix-ui/react-alert-dialog": "^1.0.4",
|
"@radix-ui/react-alert-dialog": "^1.0.4",
|
||||||
"@radix-ui/react-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-separator": "^1.0.3",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
"@radix-ui/react-tooltip": "^1.0.6",
|
"@radix-ui/react-tooltip": "^1.0.6",
|
||||||
"@vercel/analytics": "^1.0.0",
|
"@vercel/analytics": "^1.0.0",
|
||||||
"@vercel/kv": "^0.2.1",
|
"@vercel/kv": "^0.2.1",
|
||||||
|
|
|
||||||
68
pnpm-lock.yaml
generated
68
pnpm-lock.yaml
generated
|
|
@ -17,12 +17,18 @@ dependencies:
|
||||||
'@radix-ui/react-dialog':
|
'@radix-ui/react-dialog':
|
||||||
specifier: ^1.0.4
|
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)
|
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':
|
'@radix-ui/react-separator':
|
||||||
specifier: ^1.0.3
|
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)
|
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':
|
'@radix-ui/react-slot':
|
||||||
specifier: ^1.0.2
|
specifier: ^1.0.2
|
||||||
version: 1.0.2(@types/react@18.2.6)(react@18.2.0)
|
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':
|
'@radix-ui/react-tooltip':
|
||||||
specifier: ^1.0.6
|
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)
|
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
|
react: 18.2.0
|
||||||
dev: false
|
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):
|
/@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==}
|
resolution: {integrity: sha512-1CnGGfFi/bbqtJZZ0P/NQY20xdG3E0LALJaLUEoKwPLwl6PPPfbeiCqMVQnhoFRAxjJj4RpBRJzDmUgsex2tSg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -839,6 +866,33 @@ packages:
|
||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
dev: false
|
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):
|
/@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==}
|
resolution: {integrity: sha512-DmNFOiwEc2UDigsYj6clJENma58OelxD24O4IODoZ+3sQc3Zb+L8w1EP+y9laTuKCLAysPw4fD6/v0j4KNV8rg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -929,6 +983,20 @@ packages:
|
||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
dev: false
|
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):
|
/@radix-ui/react-use-rect@1.0.1(@types/react@18.2.6)(react@18.2.0):
|
||||||
resolution: {integrity: sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==}
|
resolution: {integrity: sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue