From 8cc3fea048f6368c8c03054968713cff0e7f7a5f Mon Sep 17 00:00:00 2001 From: shadcn Date: Fri, 16 Jun 2023 15:20:42 +0400 Subject: [PATCH] feat: implement sharing --- app/actions.ts | 52 +++++++++++ app/api/chat/route.ts | 2 + app/layout.tsx | 2 + components/chat.tsx | 3 +- components/clear-history.tsx | 65 +++++++++++++ components/header.tsx | 8 ++ components/sidebar-actions.tsx | 164 +++++++++++++++++++++++++++++++++ components/sidebar-footer.tsx | 16 ++++ components/sidebar-item.tsx | 81 +++------------- components/sidebar-list.tsx | 26 ++++-- components/sidebar.tsx | 18 +--- components/toaster.tsx | 3 + components/ui/icons.tsx | 17 +++- components/ui/label.tsx | 26 ++++++ components/ui/switch.tsx | 29 ++++++ lib/types.ts | 12 +++ lib/utils.ts | 9 ++ package.json | 2 + pnpm-lock.yaml | 68 ++++++++++++++ 19 files changed, 507 insertions(+), 96 deletions(-) create mode 100644 components/clear-history.tsx create mode 100644 components/sidebar-actions.tsx create mode 100644 components/sidebar-footer.tsx create mode 100644 components/toaster.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/switch.tsx diff --git a/app/actions.ts b/app/actions.ts index 42fa1cd..849bcd3 100644 --- a/app/actions.ts +++ b/app/actions.ts @@ -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) { @@ -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 +} diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 6feb8e5..1d4af1a 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -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, { diff --git a/app/layout.tsx b/app/layout.tsx index 0c80dfb..063856d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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' @@ -42,6 +43,7 @@ export default function RootLayout({ children }: RootLayoutProps) { fontMono.variable )} > +
{/* @ts-ignore */} diff --git a/components/chat.tsx b/components/chat.tsx index 165a77b..46049ea 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -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, diff --git a/components/clear-history.tsx b/components/clear-history.tsx new file mode 100644 index 0000000..1d1f7ed --- /dev/null +++ b/components/clear-history.tsx @@ -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 +} + +export function ClearHistory({ clearChats }: ClearHistoryProps) { + const [open, setOpen] = React.useState(false) + const [isPending, startTransition] = React.useTransition() + const router = useRouter() + + return ( + + + + + + + Are you absolutely sure? + + This will permanently delete your chat history and remove your data + from our servers. + + + + Cancel + { + event.preventDefault() + startTransition(async () => { + await clearChats() + setOpen(false) + router.push('/') + }) + }} + > + {isPending && } + Delete + + + + + ) +} diff --git a/components/header.tsx b/components/header.tsx index bc4619a..04b7c15 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -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() @@ -19,6 +23,10 @@ export async function Header() { {/* @ts-ignore */} + + + +
diff --git a/components/sidebar-actions.tsx b/components/sidebar-actions.tsx new file mode 100644 index 0000000..ef441ec --- /dev/null +++ b/components/sidebar-actions.tsx @@ -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 + shareChat: (chat: Chat) => ServerActionResult +} + +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 ( + <> +
+ + +
+ + + + Share link to chat + + Messages you send after creating your link won't be shared. + Anyone with the URL will be able to view the shared chat. + + +
+
{chat.title}
+
+ {formatDate(chat.createdAt)} +
+
+ +

+ Any link you have shared before will be deleted. +

+ +
+
+
+ + + + Are you absolutely sure? + + This will permanently delete your chat message and remove your + data from our servers. + + + + + Cancel + + { + event.preventDefault() + startRemoveTransition(async () => { + await removeChat({ + id: chat.id, + path: chat.path + }) + setDeleteDialogOpen(false) + router.push('/') + }) + }} + > + {isRemovePending && } + Delete + + + + + + ) +} diff --git a/components/sidebar-footer.tsx b/components/sidebar-footer.tsx new file mode 100644 index 0000000..a2e18ea --- /dev/null +++ b/components/sidebar-footer.tsx @@ -0,0 +1,16 @@ +import { cn } from '@/lib/utils' + +export function SidebarFooter({ + children, + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ {children} +
+ ) +} diff --git a/components/sidebar-item.tsx b/components/sidebar-item.tsx index 3fd627f..c300ea6 100644 --- a/components/sidebar-item.tsx +++ b/components/sidebar-item.tsx @@ -1,43 +1,29 @@ '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 { - 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' +import { buttonVariants } from '@/components/ui/button' +import { IconMessage } from '@/components/ui/icons' +import { SidebarActions } from '@/components/sidebar-actions' interface SidebarItemProps { - title: string - href: string - id: string - removeChat: (args: { id: string; path: string }) => Promise + 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 ( <>
- {title} + {chat.title}
- {isActive && ( - - )} + {children} - - - - Are you absolutely sure? - - This will permanently delete your chat message and remove your - data from our servers. - - - - Cancel - { - event.preventDefault() - startTransition(async () => { - await removeChat({ id, path: href }) - setIsOpen(false) - router.push('/') - }) - }} - > - {isPending && } - Delete - - - - ) } diff --git a/components/sidebar-list.tsx b/components/sidebar-list.tsx index c729f20..4eb3f66 100644 --- a/components/sidebar-list.tsx +++ b/components/sidebar-list.tsx @@ -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 (
{chats?.length ? (
- {chats.map(chat => ( - - ))} + {chats.map( + chat => + chat && ( + + + + ) + )}
) : (
diff --git a/components/sidebar.tsx b/components/sidebar.tsx index c210dd1..87e4eff 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -10,18 +10,14 @@ 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 ( @@ -35,18 +31,6 @@ export function Sidebar({ userId, children }: SidebarProps) { Chat History {children} -
- - {userId && ( - - )} -
) diff --git a/components/toaster.tsx b/components/toaster.tsx new file mode 100644 index 0000000..4d26934 --- /dev/null +++ b/components/toaster.tsx @@ -0,0 +1,3 @@ +'use client' + +export { Toaster } from 'react-hot-toast' diff --git a/components/ui/icons.tsx b/components/ui/icons.tsx index 60f1142..e27b0d0 100644 --- a/components/ui/icons.tsx +++ b/components/ui/icons.tsx @@ -414,6 +414,20 @@ function IconEdit({ className, ...props }: React.ComponentProps<'svg'>) { ) } +function IconShare({ className, ...props }: React.ComponentProps<'svg'>) { + return ( + + + + ) +} + export { IconEdit, IconNextChat, @@ -437,5 +451,6 @@ export { IconCopy, IconCheck, IconDownload, - IconClose + IconClose, + IconShare } diff --git a/components/ui/label.tsx b/components/ui/label.tsx new file mode 100644 index 0000000..5341821 --- /dev/null +++ b/components/ui/label.tsx @@ -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, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/components/ui/switch.tsx b/components/ui/switch.tsx new file mode 100644 index 0000000..191ef52 --- /dev/null +++ b/components/ui/switch.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/lib/types.ts b/lib/types.ts index f7eb363..9cf9176 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -5,5 +5,17 @@ export interface Chat extends Record { title: string createdAt: Date userId: string + path: string messages: Message[] } + +export interface Share { + id: string + title: string + createdAt: number + userId: string + path: string + chat: Chat +} + +export type ServerActionResult = Promise diff --git a/lib/utils.ts b/lib/utils.ts index 533bc28..cea3d17 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -32,3 +32,12 @@ export async function fetcher( 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' + }) +} diff --git a/package.json b/package.json index 6da22d3..b0ab716 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fd8386..26858c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: