diff --git a/app/actions.ts b/app/actions.ts index 42fa1cd..26d4755 100644 --- a/app/actions.ts +++ b/app/actions.ts @@ -2,9 +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' export async function getChats(userId?: string | null) { if (!userId) { @@ -51,12 +51,64 @@ export async function removeChat({ id, path }: { id: string; path: string }) { } const uid = await kv.hget(`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}`) 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 getSharedChat(id: string) { + const chat = await kv.hgetall(`chat:${id}`) + + if (!chat || !chat.sharePath) { + return null + } + + return chat +} + +export async function shareChat(chat: Chat) { + const user = await currentUser() + + if (!user || chat.userId !== user.id) { + throw new Error('Unauthorized') + } + + const payload = { + ...chat, + sharePath: `/share/${chat.id}` + } + + await kv.hmset(`chat:${chat.id}`, payload) + + 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..8537650 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,12 +1,13 @@ 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' 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 = { @@ -42,14 +43,17 @@ export default function RootLayout({ children }: RootLayoutProps) { fontMono.variable )} > - + +
{/* @ts-ignore */}
-
{children}
+
+ {children} +
-
+ diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx new file mode 100644 index 0000000..fad1b62 --- /dev/null +++ b/app/share/[id]/page.tsx @@ -0,0 +1,53 @@ +import { type Metadata } from 'next' +import { notFound } from 'next/navigation' + +import { formatDate } from '@/lib/utils' +import { getSharedChat } from '@/app/actions' +import { ChatList } from '@/components/chat-list' +import { FooterText } from '@/components/footer' + +// export const runtime = 'edge' +export const preferredRegion = 'home' + +export interface SharePageProps { + params: { + id: string + } +} + +export async function generateMetadata({ + params +}: SharePageProps): Promise { + const chat = await getSharedChat(params.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.title}

+
+ {formatDate(chat.createdAt)} · {chat.messages.length} messages +
+
+
+
+ +
+ + + ) +} diff --git a/app/sign-in/[[...sign-in]]/page.tsx b/app/sign-in/[[...sign-in]]/page.tsx index e5fc141..1e6c598 100644 --- a/app/sign-in/[[...sign-in]]/page.tsx +++ b/app/sign-in/[[...sign-in]]/page.tsx @@ -4,6 +4,7 @@ import { cn } from '@/lib/utils' import { ExternalLink } from '@/components/external-link' import { buttonVariants } from '@/components/ui/button' import { IconSpinner } from '@/components/ui/icons' +import { FooterText } from '@/components/footer' export default function SignInPage() { return ( @@ -27,14 +28,7 @@ export default function SignInPage() { } }} /> -

- Open source AI chatbot app built with{' '} - Next.js and{' '} - - Vercel KV - - . -

+ diff --git a/app/sign-up/[[...sign-up]]/page.tsx b/app/sign-up/[[...sign-up]]/page.tsx index aaadbcb..f30ecc3 100644 --- a/app/sign-up/[[...sign-up]]/page.tsx +++ b/app/sign-up/[[...sign-up]]/page.tsx @@ -4,6 +4,7 @@ import { cn } from '@/lib/utils' import { buttonVariants } from '@/components/ui/button' import { ExternalLink } from '@/components/external-link' import { IconSpinner } from '@/components/ui/icons' +import { FooterText } from '@/components/footer' export default function Page() { return ( @@ -28,14 +29,7 @@ export default function Page() { } }} /> -

- Open source AI chatbot app built with{' '} - Next.js and{' '} - - Vercel KV - - . -

+ diff --git a/components/chat-list.tsx b/components/chat-list.tsx index ef18c87..43a1588 100644 --- a/components/chat-list.tsx +++ b/components/chat-list.tsx @@ -17,7 +17,9 @@ export function ChatList({ messages }: ChatList) { {messages.map((message, index) => (
- {index < messages.length - 1 && } + {index < messages.length - 1 && ( + + )}
))} diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index a6f01de..b48e346 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -5,6 +5,7 @@ import { ExternalLink } from '@/components/external-link' import { PromptForm } from '@/components/prompt-form' import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom' import { IconRefresh, IconStop } from '@/components/ui/icons' +import { FooterText } from '@/components/footer' export interface ChatPanelProps extends Pick< @@ -70,14 +71,7 @@ export function ChatPanel({ setInput={setInput} isLoading={isLoading} /> -

- Open source AI chatbot app built with{' '} - Next.js and{' '} - - Vercel KV - - . -

+ 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/footer.tsx b/components/footer.tsx new file mode 100644 index 0000000..4b65095 --- /dev/null +++ b/components/footer.tsx @@ -0,0 +1,23 @@ +import React from 'react' + +import { ExternalLink } from '@/components/external-link' +import { cn } from '@/lib/utils' + +export function FooterText({ className, ...props }: React.ComponentProps<'p'>) { + return ( +

+ Open source AI chatbot built with{' '} + Next.js and{' '} + + Vercel KV + + . +

+ ) +} diff --git a/components/header.tsx b/components/header.tsx index 067f276..5b5fe77 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -1,4 +1,4 @@ -import { Suspense } from 'react' +import { Suspense, use } from 'react' import { cn } from '@/lib/utils' import { buttonVariants } from '@/components/ui/button' @@ -6,6 +6,11 @@ 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' +import Link from 'next/link' export async function Header() { const user = await currentUser() @@ -14,35 +19,54 @@ export async function Header() {
{/* @ts-ignore */} - - }> - {/* @ts-ignore */} - - - + {user?.id ? ( + + }> + {/* @ts-ignore */} + + + + + + + + ) : ( + + + + )}
- *]:dark:text-zinc-600', - userPreview: 'p-4 border-b border-border m-0', - userButtonPopoverActionButton: 'px-1 gap-1', - userButtonPopoverActionButtonText: - 'text-sm tracking-normal dark:text-zinc-400', - userButtonPopoverActionButtonIcon: - 'h-4 w-4 text-muted-foreground' - } - }} - /> + {user?.id ? ( + *]:dark:text-zinc-600', + userPreview: 'p-4 border-b border-border m-0', + userButtonPopoverActionButton: 'px-1 gap-1', + userButtonPopoverActionButtonText: + 'text-sm tracking-normal dark:text-zinc-400', + userButtonPopoverActionButtonIcon: + 'h-4 w-4 text-muted-foreground' + } + }} + /> + ) : ( + + Sign in + + )}
diff --git a/components/prompt-form.tsx b/components/prompt-form.tsx index 2dec66a..3656407 100644 --- a/components/prompt-form.tsx +++ b/components/prompt-form.tsx @@ -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,51 +46,49 @@ export function PromptForm({ }} ref={formRef} > - -
+
+ + + + + New Chat + + + New Chat + +