From a68eb2a0116e573cdc14bf738cc15293edfaaa49 Mon Sep 17 00:00:00 2001 From: Jared Palmer Date: Thu, 24 Oct 2024 16:35:51 -0400 Subject: [PATCH] Start on new sidebar (#456) Co-authored-by: shadcn --- ai/index.ts | 19 +- app/(chat)/actions.ts | 8 + app/(chat)/api/chat/route.ts | 47 +- app/(chat)/chat/[id]/page.tsx | 29 +- app/(chat)/layout.tsx | 22 + app/(chat)/page.tsx | 22 +- app/globals.css | 26 +- app/layout.tsx | 2 - components/custom/app-sidebar.tsx | 99 +++ components/custom/chat-header.tsx | 36 ++ components/custom/chat.tsx | 90 +-- components/custom/history.tsx | 241 ------- components/custom/message.tsx | 101 +-- components/custom/model-selector.tsx | 75 +++ components/custom/multimodal-input.tsx | 71 ++- components/custom/navbar.tsx | 72 --- components/custom/overview.tsx | 29 +- components/custom/preview-attachment.tsx | 18 +- components/custom/sidebar-history.tsx | 210 ++++++ components/custom/sidebar-toggle.tsx | 20 + components/custom/sidebar-user-nav.tsx | 67 ++ components/custom/sign-out-form.tsx | 25 + components/custom/theme-provider.tsx | 7 +- components/custom/theme-toggle.tsx | 28 - components/custom/weather.tsx | 294 ++++----- components/ui/button.tsx | 48 +- components/ui/card.tsx | 79 +++ components/ui/dropdown-menu.tsx | 92 +-- components/ui/select.tsx | 160 +++++ components/ui/separator.tsx | 31 + components/ui/sidebar.tsx | 773 +++++++++++++++++++++++ components/ui/skeleton.tsx | 15 + components/ui/tooltip.tsx | 49 ++ hooks/use-mobile.tsx | 19 + lib/model.ts | 17 + next.config.ts | 6 +- package.json | 2 + pnpm-lock.yaml | 98 ++- prettier.config.cjs | 9 + tailwind.config.ts | 82 +-- tsconfig.json | 8 +- 41 files changed, 2348 insertions(+), 798 deletions(-) create mode 100644 app/(chat)/actions.ts create mode 100644 app/(chat)/layout.tsx create mode 100644 components/custom/app-sidebar.tsx create mode 100644 components/custom/chat-header.tsx delete mode 100644 components/custom/history.tsx create mode 100644 components/custom/model-selector.tsx delete mode 100644 components/custom/navbar.tsx create mode 100644 components/custom/sidebar-history.tsx create mode 100644 components/custom/sidebar-toggle.tsx create mode 100644 components/custom/sidebar-user-nav.tsx create mode 100644 components/custom/sign-out-form.tsx delete mode 100644 components/custom/theme-toggle.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sidebar.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 hooks/use-mobile.tsx create mode 100644 lib/model.ts create mode 100644 prettier.config.cjs diff --git a/ai/index.ts b/ai/index.ts index 0d84e4fed..450d03ebd 100644 --- a/ai/index.ts +++ b/ai/index.ts @@ -1,8 +1,13 @@ -import { openai } from "@ai-sdk/openai"; -import { experimental_wrapLanguageModel as wrapLanguageModel } from "ai"; -import { customMiddleware } from "./custom-middleware"; +import { openai } from '@ai-sdk/openai'; +import { experimental_wrapLanguageModel as wrapLanguageModel } from 'ai'; -export const customModel = wrapLanguageModel({ - model: openai("gpt-4o"), - middleware: customMiddleware, -}); +import { type Model } from '@/lib/model'; + +import { customMiddleware } from './custom-middleware'; + +export const customModel = (modelName: Model['name']) => { + return wrapLanguageModel({ + model: openai(modelName), + middleware: customMiddleware, + }); +}; diff --git a/app/(chat)/actions.ts b/app/(chat)/actions.ts new file mode 100644 index 000000000..3263fd1b6 --- /dev/null +++ b/app/(chat)/actions.ts @@ -0,0 +1,8 @@ +'use server'; + +import { cookies } from 'next/headers'; + +export async function saveModel(model: string) { + const cookieStore = await cookies(); + cookieStore.set('model', model); +} diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 4e2c08888..8249c7bda 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -1,38 +1,47 @@ -import { convertToCoreMessages, Message, streamText } from "ai"; -import { z } from "zod"; +import { convertToCoreMessages, Message, streamText } from 'ai'; +import { z } from 'zod'; -import { customModel } from "@/ai"; -import { auth } from "@/app/(auth)/auth"; -import { deleteChatById, getChatById, saveChat } from "@/db/queries"; +import { customModel } from '@/ai'; +import { auth } from '@/app/(auth)/auth'; +import { deleteChatById, getChatById, saveChat } from '@/db/queries'; +import { Model, models } from '@/lib/model'; export async function POST(request: Request) { - const { id, messages }: { id: string; messages: Array } = + const { + id, + messages, + model, + }: { id: string; messages: Array; model: Model['name'] } = await request.json(); const session = await auth(); if (!session) { - return new Response("Unauthorized", { status: 401 }); + return new Response('Unauthorized', { status: 401 }); + } + + if (!models.find((m) => m.name === model)) { + return new Response('Model not found', { status: 404 }); } const coreMessages = convertToCoreMessages(messages); const result = await streamText({ - model: customModel, + model: customModel(model), system: - "you are a friendly assistant! keep your responses concise and helpful.", + 'you are a friendly assistant! keep your responses concise and helpful.', messages: coreMessages, maxSteps: 5, tools: { getWeather: { - description: "Get the current weather at a location", + description: 'Get the current weather at a location', parameters: z.object({ latitude: z.number(), longitude: z.number(), }), execute: async ({ latitude, longitude }) => { const response = await fetch( - `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m&hourly=temperature_2m&daily=sunrise,sunset&timezone=auto`, + `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m&hourly=temperature_2m&daily=sunrise,sunset&timezone=auto` ); const weatherData = await response.json(); @@ -49,13 +58,13 @@ export async function POST(request: Request) { userId: session.user.id, }); } catch (error) { - console.error("Failed to save chat"); + console.error('Failed to save chat'); } } }, experimental_telemetry: { isEnabled: true, - functionId: "stream-text", + functionId: 'stream-text', }, }); @@ -64,30 +73,30 @@ export async function POST(request: Request) { export async function DELETE(request: Request) { const { searchParams } = new URL(request.url); - const id = searchParams.get("id"); + const id = searchParams.get('id'); if (!id) { - return new Response("Not Found", { status: 404 }); + return new Response('Not Found', { status: 404 }); } const session = await auth(); if (!session || !session.user) { - return new Response("Unauthorized", { status: 401 }); + return new Response('Unauthorized', { status: 401 }); } try { const chat = await getChatById({ id }); if (chat.userId !== session.user.id) { - return new Response("Unauthorized", { status: 401 }); + return new Response('Unauthorized', { status: 401 }); } await deleteChatById({ id }); - return new Response("Chat deleted", { status: 200 }); + return new Response('Chat deleted', { status: 200 }); } catch (error) { - return new Response("An error occurred while processing your request", { + return new Response('An error occurred while processing your request', { status: 500, }); } diff --git a/app/(chat)/chat/[id]/page.tsx b/app/(chat)/chat/[id]/page.tsx index 7963b8f11..6f89ed00e 100644 --- a/app/(chat)/chat/[id]/page.tsx +++ b/app/(chat)/chat/[id]/page.tsx @@ -1,11 +1,13 @@ -import { CoreMessage } from "ai"; -import { notFound } from "next/navigation"; +import { CoreMessage } from 'ai'; +import { cookies } from 'next/headers'; +import { notFound } from 'next/navigation'; -import { auth } from "@/app/(auth)/auth"; -import { Chat as PreviewChat } from "@/components/custom/chat"; -import { getChatById } from "@/db/queries"; -import { Chat } from "@/db/schema"; -import { convertToUIMessages, generateUUID } from "@/lib/utils"; +import { auth } from '@/app/(auth)/auth'; +import { Chat as PreviewChat } from '@/components/custom/chat'; +import { getChatById } from '@/db/queries'; +import { Chat } from '@/db/schema'; +import { DEFAULT_MODEL_NAME, models } from '@/lib/model'; +import { convertToUIMessages } from '@/lib/utils'; export default async function Page(props: { params: Promise }) { const params = await props.params; @@ -32,5 +34,16 @@ export default async function Page(props: { params: Promise }) { return notFound(); } - return ; + const cookieStore = await cookies(); + const value = cookieStore.get('model')?.value; + const selectedModelName = + models.find((m) => m.name === value)?.name || DEFAULT_MODEL_NAME; + + return ( + + ); } diff --git a/app/(chat)/layout.tsx b/app/(chat)/layout.tsx new file mode 100644 index 000000000..eb6f8f99a --- /dev/null +++ b/app/(chat)/layout.tsx @@ -0,0 +1,22 @@ +import { cookies } from 'next/headers'; + +import { AppSidebar } from '@/components/custom/app-sidebar'; +import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; + +import { auth } from '../(auth)/auth'; + +export default async function Layout({ + children, +}: { + children: React.ReactNode; +}) { + const [session, cookieStore] = await Promise.all([auth(), cookies()]); + const isCollapsed = cookieStore.get('sidebar:state')?.value !== 'true'; + + return ( + + + {children} + + ); +} diff --git a/app/(chat)/page.tsx b/app/(chat)/page.tsx index c2af86b93..d6b4fd463 100644 --- a/app/(chat)/page.tsx +++ b/app/(chat)/page.tsx @@ -1,7 +1,23 @@ -import { Chat } from "@/components/custom/chat"; -import { generateUUID } from "@/lib/utils"; +import { cookies } from 'next/headers'; + +import { Chat } from '@/components/custom/chat'; +import { DEFAULT_MODEL_NAME, models } from '@/lib/model'; +import { generateUUID } from '@/lib/utils'; export default async function Page() { const id = generateUUID(); - return ; + + const cookieStore = await cookies(); + const value = cookieStore.get('model')?.value; + const selectedModelName = + models.find((m) => m.name === value)?.name || DEFAULT_MODEL_NAME; + + return ( + + ); } diff --git a/app/globals.css b/app/globals.css index e525b6c76..6ad8fd72d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -49,6 +49,14 @@ --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; --radius: 0.5rem; + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 10% 3.9%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 5.9% 94%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; } .dark { --background: 240 10% 3.9%; @@ -75,6 +83,14 @@ --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; } } @@ -88,17 +104,17 @@ } @font-face { - font-family: "geist"; + font-family: 'geist'; font-style: normal; font-weight: 100 900; - src: url(/fonts/geist.woff2) format("woff2"); + src: url(/fonts/geist.woff2) format('woff2'); } @font-face { - font-family: "geist-mono"; + font-family: 'geist-mono'; font-style: normal; font-weight: 100 900; - src: url(/fonts/geist-mono.woff2) format("woff2"); + src: url(/fonts/geist-mono.woff2) format('woff2'); } } @@ -107,7 +123,7 @@ pointer-events: none !important; } - *[class^="text-"] { + *[class^='text-'] { color: transparent; @apply rounded-md bg-foreground/20 select-none animate-pulse; } diff --git a/app/layout.tsx b/app/layout.tsx index d7d9e238f..81b5ea04a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,7 +1,6 @@ import { Metadata } from 'next'; import { Toaster } from 'sonner'; -import { Navbar } from '@/components/custom/navbar'; import { ThemeProvider } from '@/components/custom/theme-provider'; import './globals.css'; @@ -65,7 +64,6 @@ export default async function RootLayout({ disableTransitionOnChange > - {children} diff --git a/components/custom/app-sidebar.tsx b/components/custom/app-sidebar.tsx new file mode 100644 index 000000000..43e46755c --- /dev/null +++ b/components/custom/app-sidebar.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { Plus } from 'lucide-react'; +import Link from 'next/link'; +import { type User } from 'next-auth'; + +import { VercelIcon } from '@/components/custom/icons'; +import { SidebarHistory } from '@/components/custom/sidebar-history'; +import { SidebarUserNav } from '@/components/custom/sidebar-user-nav'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarHeader, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from '@/components/ui/sidebar'; +import { BetterTooltip } from '@/components/ui/tooltip'; + +export function AppSidebar({ user }: { user: User | undefined }) { + const { setOpenMobile } = useSidebar(); + + return ( + + + + + + setOpenMobile(false)}> + + Chatbot + + + + + + setOpenMobile(false)}> + + + + + + + + + + + + + + + + Deploy + + + Deploy your own + + Open Source Chatbot template built with Next.js and the AI SDK + by Vercel. + + + + + + + + + {user && ( + + + + + + )} + + + ); +} diff --git a/components/custom/chat-header.tsx b/components/custom/chat-header.tsx new file mode 100644 index 000000000..44d7da253 --- /dev/null +++ b/components/custom/chat-header.tsx @@ -0,0 +1,36 @@ +import { Plus } from 'lucide-react'; +import Link from 'next/link'; + +import { ModelSelector } from '@/components/custom/model-selector'; +import { SidebarToggle } from '@/components/custom/sidebar-toggle'; +import { Button } from '@/components/ui/button'; +import { BetterTooltip } from '@/components/ui/tooltip'; +import { Model } from '@/lib/model'; + +export function ChatHeader({ + selectedModelName, +}: { + selectedModelName: Model['name']; +}) { + return ( +
+ + + + + +
+ ); +} diff --git a/components/custom/chat.tsx b/components/custom/chat.tsx index 382a89c48..bd4cc9aec 100644 --- a/components/custom/chat.tsx +++ b/components/custom/chat.tsx @@ -1,28 +1,32 @@ -"use client"; +'use client'; -import { Attachment, Message } from "ai"; -import { useChat } from "ai/react"; -import { useState } from "react"; +import { Attachment, Message } from 'ai'; +import { useChat } from 'ai/react'; +import { useState } from 'react'; -import { Message as PreviewMessage } from "@/components/custom/message"; -import { useScrollToBottom } from "@/components/custom/use-scroll-to-bottom"; +import { ChatHeader } from '@/components/custom/chat-header'; +import { Message as PreviewMessage } from '@/components/custom/message'; +import { useScrollToBottom } from '@/components/custom/use-scroll-to-bottom'; +import { Model } from '@/lib/model'; -import { MultimodalInput } from "./multimodal-input"; -import { Overview } from "./overview"; +import { MultimodalInput } from './multimodal-input'; +import { Overview } from './overview'; export function Chat({ id, initialMessages, + selectedModelName, }: { id: string; initialMessages: Array; + selectedModelName: Model['name']; }) { const { messages, handleSubmit, input, setInput, append, isLoading, stop } = useChat({ - body: { id }, + body: { id, model: selectedModelName }, initialMessages, onFinish: () => { - window.history.replaceState({}, "", `/chat/${id}`); + window.history.replaceState({}, '', `/chat/${id}`); }, }); @@ -32,44 +36,42 @@ export function Chat({ const [attachments, setAttachments] = useState>([]); return ( -
-
-
- {messages.length === 0 && } - - {messages.map((message) => ( - - ))} +
+ +
+ {messages.length === 0 && } -
( + -
+ ))} -
- - +
+
+ +
); } diff --git a/components/custom/history.tsx b/components/custom/history.tsx deleted file mode 100644 index 09e3907de..000000000 --- a/components/custom/history.tsx +++ /dev/null @@ -1,241 +0,0 @@ -"use client"; - -import * as VisuallyHidden from "@radix-ui/react-visually-hidden"; -import cx from "classnames"; -import Link from "next/link"; -import { useParams, usePathname } from "next/navigation"; -import { User } from "next-auth"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import useSWR from "swr"; - -import { Chat } from "@/db/schema"; -import { fetcher, getTitleFromChat } from "@/lib/utils"; - -import { - InfoIcon, - MenuIcon, - MoreHorizontalIcon, - PencilEditIcon, - TrashIcon, -} from "./icons"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "../ui/alert-dialog"; -import { Button } from "../ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "../ui/dropdown-menu"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "../ui/sheet"; - -export const History = ({ user }: { user: User | undefined }) => { - const { id } = useParams(); - const pathname = usePathname(); - - const [isHistoryVisible, setIsHistoryVisible] = useState(false); - const { - data: history, - isLoading, - mutate, - } = useSWR>(user ? "/api/history" : null, fetcher, { - fallbackData: [], - }); - - useEffect(() => { - mutate(); - }, [pathname, mutate]); - - const [deleteId, setDeleteId] = useState(null); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - - const handleDelete = async () => { - const deletePromise = fetch(`/api/chat?id=${deleteId}`, { - method: "DELETE", - }); - - toast.promise(deletePromise, { - loading: "Deleting chat...", - success: () => { - mutate((history) => { - if (history) { - return history.filter((h) => h.id !== id); - } - }); - return "Chat deleted successfully"; - }, - error: "Failed to delete chat", - }); - - setShowDeleteDialog(false); - }; - - return ( - <> - - - { - setIsHistoryVisible(state); - }} - > - - - - History - - {history === undefined ? "loading" : history.length} chats - - - - -
-
-
History
- -
- {history === undefined ? "loading" : history.length} chats -
-
-
- -
- {user && ( - - )} - -
- {!user ? ( -
- -
Login to save and revisit previous chats!
-
- ) : null} - - {!isLoading && history?.length === 0 && user ? ( -
- -
No chats found
-
- ) : null} - - {isLoading && user ? ( -
- {[44, 32, 28, 52].map((item) => ( -
-
-
- ))} -
- ) : null} - - {history && - history.map((chat) => ( -
- - - - - - - - - - - - -
- ))} -
-
- - - - - - - Are you absolutely sure? - - This action cannot be undone. This will permanently delete your - chat and remove it from our servers. - - - - Cancel - - Continue - - - - - - ); -}; diff --git a/components/custom/message.tsx b/components/custom/message.tsx index ae1765902..3399c18f8 100644 --- a/components/custom/message.tsx +++ b/components/custom/message.tsx @@ -1,13 +1,13 @@ -"use client"; +'use client'; -import { Attachment, ToolInvocation } from "ai"; -import { motion } from "framer-motion"; -import { ReactNode } from "react"; +import { Attachment, ToolInvocation } from 'ai'; +import { motion } from 'framer-motion'; +import { Sparkles } from 'lucide-react'; +import { ReactNode } from 'react'; -import { BotIcon, UserIcon } from "./icons"; -import { Markdown } from "./markdown"; -import { PreviewAttachment } from "./preview-attachment"; -import { Weather } from "./weather"; +import { Markdown } from './markdown'; +import { PreviewAttachment } from './preview-attachment'; +import { Weather } from './weather'; export const Message = ({ role, @@ -22,54 +22,61 @@ export const Message = ({ }) => { return ( -
- {role === "assistant" ? : } -
- -
- {content && ( -
- {content as string} +
+ {role === 'assistant' && ( +
+
)} +
+ {content && ( +
+ {content as string} +
+ )} - {toolInvocations && ( -
- {toolInvocations.map((toolInvocation) => { - const { toolName, toolCallId, state } = toolInvocation; + {toolInvocations && toolInvocations.length > 0 ? ( +
+ {toolInvocations.map((toolInvocation) => { + const { toolName, toolCallId, state } = toolInvocation; - if (state === "result") { - const { result } = toolInvocation; + if (state === 'result') { + const { result } = toolInvocation; - return ( -
- {toolName === "getWeather" ? ( - - ) : null} -
- ); - } else { - return ( -
- {toolName === "getWeather" ? : null} -
- ); - } - })} -
- )} + return ( +
+ {toolName === 'getWeather' ? ( + + ) : null} +
+ ); + } else { + return ( +
+ {toolName === 'getWeather' ? : null} +
+ ); + } + })} +
+ ) : null} - {attachments && ( -
- {attachments.map((attachment) => ( - - ))} -
- )} + {attachments && ( +
+ {attachments.map((attachment) => ( + + ))} +
+ )} +
); diff --git a/components/custom/model-selector.tsx b/components/custom/model-selector.tsx new file mode 100644 index 000000000..56da494d5 --- /dev/null +++ b/components/custom/model-selector.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { Check, ChevronDown } from 'lucide-react'; +import { startTransition, useMemo, useOptimistic, useState } from 'react'; + +import { saveModel } from '@/app/(chat)/actions'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { type Model, models } from '@/lib/model'; +import { cn } from '@/lib/utils'; + +export function ModelSelector({ + selectedModelName, + className, +}: { + selectedModelName: Model['name']; +} & React.ComponentProps) { + const [open, setOpen] = useState(false); + const [optimisticModelName, setOptimisticModelName] = + useOptimistic(selectedModelName); + + const selectModel = useMemo( + () => models.find((model) => model.name === optimisticModelName), + [optimisticModelName] + ); + + return ( + + svg]:!size-5 md:[&>svg]:!size-4', + className + )} + > + + + + {models.map((model) => ( + { + setOpen(false); + + startTransition(() => { + setOptimisticModelName(model.name); + saveModel(model.name); + }); + }} + className="gap-4 group/item" + data-active={model.name === optimisticModelName} + > +
+ {model.label} + {model.description && ( +
+ {model.description} +
+ )} +
+ +
+ ))} +
+
+ ); +} diff --git a/components/custom/multimodal-input.tsx b/components/custom/multimodal-input.tsx index 0f8ca3d0c..04d6c5f21 100644 --- a/components/custom/multimodal-input.tsx +++ b/components/custom/multimodal-input.tsx @@ -1,7 +1,7 @@ -"use client"; +'use client'; -import { Attachment, ChatRequestOptions, CreateMessage, Message } from "ai"; -import { motion } from "framer-motion"; +import { Attachment, ChatRequestOptions, CreateMessage, Message } from 'ai'; +import { motion } from 'framer-motion'; import React, { useRef, useEffect, @@ -10,24 +10,24 @@ import React, { Dispatch, SetStateAction, ChangeEvent, -} from "react"; -import { toast } from "sonner"; +} from 'react'; +import { toast } from 'sonner'; -import { ArrowUpIcon, PaperclipIcon, StopIcon } from "./icons"; -import { PreviewAttachment } from "./preview-attachment"; -import useWindowSize from "./use-window-size"; -import { Button } from "../ui/button"; -import { Textarea } from "../ui/textarea"; +import { ArrowUpIcon, PaperclipIcon, StopIcon } from './icons'; +import { PreviewAttachment } from './preview-attachment'; +import useWindowSize from './use-window-size'; +import { Button } from '../ui/button'; +import { Textarea } from '../ui/textarea'; const suggestedActions = [ { - title: "What is the weather", - label: "in San Francisco?", - action: "What is the weather in San Francisco?", + title: 'What is the weather', + label: 'in San Francisco?', + action: 'What is the weather in San Francisco?', }, { title: "Answer like I'm 5,", - label: "why is the sky blue?", + label: 'why is the sky blue?', action: "Answer like I'm 5, why is the sky blue?", }, ]; @@ -52,13 +52,13 @@ export function MultimodalInput({ messages: Array; append: ( message: Message | CreateMessage, - chatRequestOptions?: ChatRequestOptions, + chatRequestOptions?: ChatRequestOptions ) => Promise; handleSubmit: ( event?: { preventDefault?: () => void; }, - chatRequestOptions?: ChatRequestOptions, + chatRequestOptions?: ChatRequestOptions ) => void; }) { const textareaRef = useRef(null); @@ -72,7 +72,7 @@ export function MultimodalInput({ const adjustHeight = () => { if (textareaRef.current) { - textareaRef.current.style.height = "auto"; + textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`; } }; @@ -99,11 +99,11 @@ export function MultimodalInput({ const uploadFile = async (file: File) => { const formData = new FormData(); - formData.append("file", file); + formData.append('file', file); try { const response = await fetch(`/api/files/upload`, { - method: "POST", + method: 'POST', body: formData, }); @@ -121,7 +121,7 @@ export function MultimodalInput({ toast.error(error); } } catch (error) { - toast.error("Failed to upload file, please try again!"); + toast.error('Failed to upload file, please try again!'); } }; @@ -135,7 +135,7 @@ export function MultimodalInput({ const uploadPromises = files.map((file) => uploadFile(file)); const uploadedAttachments = await Promise.all(uploadPromises); const successfullyUploadedAttachments = uploadedAttachments.filter( - (attachment) => attachment !== undefined, + (attachment) => attachment !== undefined ); setAttachments((currentAttachments) => [ @@ -143,12 +143,12 @@ export function MultimodalInput({ ...successfullyUploadedAttachments, ]); } catch (error) { - console.error("Error uploading files!", error); + console.error('Error uploading files!', error); } finally { setUploadQueue([]); } }, - [setAttachments], + [setAttachments] ); return ( @@ -156,7 +156,7 @@ export function MultimodalInput({ {messages.length === 0 && attachments.length === 0 && uploadQueue.length === 0 && ( -
+
{suggestedActions.map((suggestedAction, index) => ( 1 ? "hidden sm:block" : "block"} + className={index > 1 ? 'hidden sm:block' : 'block'} > - + ))}
@@ -204,9 +205,9 @@ export function MultimodalInput({ @@ -219,14 +220,14 @@ export function MultimodalInput({ placeholder="Send a message..." value={input} onChange={handleInput} - className="min-h-[24px] overflow-hidden resize-none rounded-lg text-base bg-muted" + className="min-h-[24px] overflow-hidden resize-none rounded-xl p-4 focus-visible:ring-0 focus-visible:ring-offset-0 text-base bg-muted border-none" rows={3} onKeyDown={(event) => { - if (event.key === "Enter" && !event.shiftKey) { + if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); if (isLoading) { - toast.error("Please wait for the model to finish its response!"); + toast.error('Please wait for the model to finish its response!'); } else { submitForm(); } diff --git a/components/custom/navbar.tsx b/components/custom/navbar.tsx deleted file mode 100644 index 3539e6efb..000000000 --- a/components/custom/navbar.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import Form from 'next/form'; -import Link from 'next/link'; - -import { auth, signOut } from '@/app/(auth)/auth'; - -import { History } from './history'; -import { ThemeToggle } from './theme-toggle'; -import { Button } from '../ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '../ui/dropdown-menu'; - -export const Navbar = async () => { - let session = await auth(); - - return ( - <> -
-
- -
-
Next.js Chatbot
-
-
- - {session ? ( - - - - - - - - - -
{ - 'use server'; - - await signOut({ - redirectTo: '/', - }); - }} - > - -
-
-
-
- ) : ( - - )} -
- - ); -}; diff --git a/components/custom/overview.tsx b/components/custom/overview.tsx index 379655ace..7de9b7750 100644 --- a/components/custom/overview.tsx +++ b/components/custom/overview.tsx @@ -1,41 +1,40 @@ -import { motion } from "framer-motion"; -import Link from "next/link"; +import { motion } from 'framer-motion'; +import Link from 'next/link'; -import { LogoOpenAI, MessageIcon, VercelIcon } from "./icons"; +import { MessageIcon, VercelIcon } from './icons'; export const Overview = () => { return ( -
-

- +

+

+ + - +

This is an open source Chatbot template built with Next.js and the AI - SDK by Vercel. It uses the{" "} - streamText{" "} - function in the server and the{" "} + SDK by Vercel. It uses the{' '} + streamText{' '} + function in the server and the{' '} useChat hook on the client to create a seamless chat experience.

- {" "} - You can learn more about the AI SDK by visiting the{" "} + You can learn more about the AI SDK by visiting the{' '} - Docs + docs .

diff --git a/components/custom/preview-attachment.tsx b/components/custom/preview-attachment.tsx index f43958402..a21d67345 100644 --- a/components/custom/preview-attachment.tsx +++ b/components/custom/preview-attachment.tsx @@ -1,6 +1,6 @@ -import { Attachment } from "ai"; +import { Attachment } from 'ai'; -import { LoaderIcon } from "./icons"; +import { LoaderIcon } from './icons'; export const PreviewAttachment = ({ attachment, @@ -12,18 +12,18 @@ export const PreviewAttachment = ({ const { name, url, contentType } = attachment; return ( - (
-
+
+
{contentType ? ( - contentType.startsWith("image") ? ( + contentType.startsWith('image') ? ( // NOTE: it is recommended to use next/image for images // eslint-disable-next-line @next/next/no-img-element - ({name) + /> ) : (
) @@ -38,6 +38,6 @@ export const PreviewAttachment = ({ )}
{name}
-
) +
); }; diff --git a/components/custom/sidebar-history.tsx b/components/custom/sidebar-history.tsx new file mode 100644 index 000000000..2e7583417 --- /dev/null +++ b/components/custom/sidebar-history.tsx @@ -0,0 +1,210 @@ +'use client'; + +import Link from 'next/link'; +import { useParams, usePathname, useRouter } from 'next/navigation'; +import { type User } from 'next-auth'; +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import useSWR from 'swr'; + +import { + InfoIcon, + MoreHorizontalIcon, + TrashIcon, +} from '@/components/custom/icons'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from '@/components/ui/sidebar'; +import { Chat } from '@/db/schema'; +import { fetcher, getTitleFromChat } from '@/lib/utils'; + +export function SidebarHistory({ user }: { user: User | undefined }) { + const { setOpenMobile } = useSidebar(); + const { id } = useParams(); + const pathname = usePathname(); + const { + data: history, + isLoading, + mutate, + } = useSWR>(user ? '/api/history' : null, fetcher, { + fallbackData: [], + }); + + useEffect(() => { + mutate(); + }, [pathname, mutate]); + + const [deleteId, setDeleteId] = useState(null); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const router = useRouter(); + const handleDelete = async () => { + const deletePromise = fetch(`/api/chat?id=${deleteId}`, { + method: 'DELETE', + }); + + toast.promise(deletePromise, { + loading: 'Deleting chat...', + success: () => { + mutate((history) => { + if (history) { + return history.filter((h) => h.id !== id); + } + }); + return 'Chat deleted successfully'; + }, + error: 'Failed to delete chat', + }); + + setShowDeleteDialog(false); + if (deleteId === id) { + router.push('/'); + } + }; + + if (!user) { + return ( + + History + +
+ +
Login to save and revisit previous chats!
+
+
+
+ ); + } + + if (isLoading) { + return ( + + History + +
+ {[44, 32, 28, 64, 52].map((item) => ( +
+
+
+ ))} +
+ + + ); + } + + if (history?.length === 0) { + return ( + + History + + + + + + No chats found + + + + + + ); + } + + return ( + <> + + History + + + {history && + history.map((chat) => ( + + + setOpenMobile(false)} + > + {getTitleFromChat(chat)} + + + + + + + More + + + + { + setDeleteId(chat.id); + setShowDeleteDialog(true); + }} + > + + Delete + + + + + ))} + + + + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete your + chat and remove it from our servers. + + + + Cancel + + Continue + + + + + + ); +} diff --git a/components/custom/sidebar-toggle.tsx b/components/custom/sidebar-toggle.tsx new file mode 100644 index 000000000..536c98f2a --- /dev/null +++ b/components/custom/sidebar-toggle.tsx @@ -0,0 +1,20 @@ +import { ComponentProps } from 'react'; + +import { SidebarTrigger } from '@/components/ui/sidebar'; +import { BetterTooltip } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + +export function SidebarToggle({ + className, +}: ComponentProps) { + return ( + + svg]:!size-5 md:[&>svg]:!size-4', + className + )} + /> + + ); +} diff --git a/components/custom/sidebar-user-nav.tsx b/components/custom/sidebar-user-nav.tsx new file mode 100644 index 000000000..cfda9813a --- /dev/null +++ b/components/custom/sidebar-user-nav.tsx @@ -0,0 +1,67 @@ +'use client'; +import { ChevronUp } from 'lucide-react'; +import Image from 'next/image'; +import { type User } from 'next-auth'; +import { signOut } from 'next-auth/react'; +import { useTheme } from 'next-themes'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from '@/components/ui/sidebar'; + +export function SidebarUserNav({ user }: { user: User }) { + const { setTheme, theme } = useTheme(); + return ( + + + + + + {user.email + {user?.email} + + + + + setTheme(theme === 'dark' ? 'light' : 'dark')} + > + {`Toggle ${theme === 'light' ? 'dark' : 'light'} mode`} + + + + + + + + + + ); +} diff --git a/components/custom/sign-out-form.tsx b/components/custom/sign-out-form.tsx new file mode 100644 index 000000000..7fe9ee667 --- /dev/null +++ b/components/custom/sign-out-form.tsx @@ -0,0 +1,25 @@ +import Form from 'next/form'; + +import { signOut } from '@/app/(auth)/auth'; + +export const SignOutForm = () => { + return ( +
{ + 'use server'; + + await signOut({ + redirectTo: '/', + }); + }} + > + +
+ ); +}; diff --git a/components/custom/theme-provider.tsx b/components/custom/theme-provider.tsx index 08c7e516a..1f84abd1a 100644 --- a/components/custom/theme-provider.tsx +++ b/components/custom/theme-provider.tsx @@ -1,8 +1,7 @@ -"use client"; +'use client'; -import { ThemeProvider as NextThemesProvider } from "next-themes"; -import { type ThemeProviderProps } from "next-themes/dist/types"; -import * as React from "react"; +import { ThemeProvider as NextThemesProvider } from 'next-themes'; +import { type ThemeProviderProps } from 'next-themes/dist/types'; export function ThemeProvider({ children, ...props }: ThemeProviderProps) { return {children}; diff --git a/components/custom/theme-toggle.tsx b/components/custom/theme-toggle.tsx deleted file mode 100644 index eef53eeda..000000000 --- a/components/custom/theme-toggle.tsx +++ /dev/null @@ -1,28 +0,0 @@ -'use client'; - -import { useTheme } from 'next-themes'; -import { useEffect, useLayoutEffect, useState } from 'react'; - -export function ThemeToggle() { - const { setTheme, theme } = useTheme(); - const [mounted, setMounted] = useState(false); - - useLayoutEffect(() => { - setMounted(true); - }, []); - - if (!mounted) { - return
; - } - - return ( -
{ - setTheme(theme === 'dark' ? 'light' : 'dark'); - }} - > - {`Toggle ${theme === 'light' ? 'dark' : 'light'} mode`} -
- ); -} diff --git a/components/custom/weather.tsx b/components/custom/weather.tsx index 28fdb8e3b..fe64f0fb3 100644 --- a/components/custom/weather.tsx +++ b/components/custom/weather.tsx @@ -1,8 +1,8 @@ -"use client"; +'use client'; -import cx from "classnames"; -import { format, isWithinInterval } from "date-fns"; -import { useEffect, useState } from "react"; +import cx from 'classnames'; +import { format, isWithinInterval } from 'date-fns'; +import { useEffect, useState } from 'react'; interface WeatherAtLocation { latitude: number; @@ -47,114 +47,114 @@ const SAMPLE = { longitude: -122.41286, generationtime_ms: 0.027894973754882812, utc_offset_seconds: 0, - timezone: "GMT", - timezone_abbreviation: "GMT", + timezone: 'GMT', + timezone_abbreviation: 'GMT', elevation: 18, - current_units: { time: "iso8601", interval: "seconds", temperature_2m: "°C" }, - current: { time: "2024-10-07T19:30", interval: 900, temperature_2m: 29.3 }, - hourly_units: { time: "iso8601", temperature_2m: "°C" }, + current_units: { time: 'iso8601', interval: 'seconds', temperature_2m: '°C' }, + current: { time: '2024-10-07T19:30', interval: 900, temperature_2m: 29.3 }, + hourly_units: { time: 'iso8601', temperature_2m: '°C' }, hourly: { time: [ - "2024-10-07T00:00", - "2024-10-07T01:00", - "2024-10-07T02:00", - "2024-10-07T03:00", - "2024-10-07T04:00", - "2024-10-07T05:00", - "2024-10-07T06:00", - "2024-10-07T07:00", - "2024-10-07T08:00", - "2024-10-07T09:00", - "2024-10-07T10:00", - "2024-10-07T11:00", - "2024-10-07T12:00", - "2024-10-07T13:00", - "2024-10-07T14:00", - "2024-10-07T15:00", - "2024-10-07T16:00", - "2024-10-07T17:00", - "2024-10-07T18:00", - "2024-10-07T19:00", - "2024-10-07T20:00", - "2024-10-07T21:00", - "2024-10-07T22:00", - "2024-10-07T23:00", - "2024-10-08T00:00", - "2024-10-08T01:00", - "2024-10-08T02:00", - "2024-10-08T03:00", - "2024-10-08T04:00", - "2024-10-08T05:00", - "2024-10-08T06:00", - "2024-10-08T07:00", - "2024-10-08T08:00", - "2024-10-08T09:00", - "2024-10-08T10:00", - "2024-10-08T11:00", - "2024-10-08T12:00", - "2024-10-08T13:00", - "2024-10-08T14:00", - "2024-10-08T15:00", - "2024-10-08T16:00", - "2024-10-08T17:00", - "2024-10-08T18:00", - "2024-10-08T19:00", - "2024-10-08T20:00", - "2024-10-08T21:00", - "2024-10-08T22:00", - "2024-10-08T23:00", - "2024-10-09T00:00", - "2024-10-09T01:00", - "2024-10-09T02:00", - "2024-10-09T03:00", - "2024-10-09T04:00", - "2024-10-09T05:00", - "2024-10-09T06:00", - "2024-10-09T07:00", - "2024-10-09T08:00", - "2024-10-09T09:00", - "2024-10-09T10:00", - "2024-10-09T11:00", - "2024-10-09T12:00", - "2024-10-09T13:00", - "2024-10-09T14:00", - "2024-10-09T15:00", - "2024-10-09T16:00", - "2024-10-09T17:00", - "2024-10-09T18:00", - "2024-10-09T19:00", - "2024-10-09T20:00", - "2024-10-09T21:00", - "2024-10-09T22:00", - "2024-10-09T23:00", - "2024-10-10T00:00", - "2024-10-10T01:00", - "2024-10-10T02:00", - "2024-10-10T03:00", - "2024-10-10T04:00", - "2024-10-10T05:00", - "2024-10-10T06:00", - "2024-10-10T07:00", - "2024-10-10T08:00", - "2024-10-10T09:00", - "2024-10-10T10:00", - "2024-10-10T11:00", - "2024-10-10T12:00", - "2024-10-10T13:00", - "2024-10-10T14:00", - "2024-10-10T15:00", - "2024-10-10T16:00", - "2024-10-10T17:00", - "2024-10-10T18:00", - "2024-10-10T19:00", - "2024-10-10T20:00", - "2024-10-10T21:00", - "2024-10-10T22:00", - "2024-10-10T23:00", - "2024-10-11T00:00", - "2024-10-11T01:00", - "2024-10-11T02:00", - "2024-10-11T03:00", + '2024-10-07T00:00', + '2024-10-07T01:00', + '2024-10-07T02:00', + '2024-10-07T03:00', + '2024-10-07T04:00', + '2024-10-07T05:00', + '2024-10-07T06:00', + '2024-10-07T07:00', + '2024-10-07T08:00', + '2024-10-07T09:00', + '2024-10-07T10:00', + '2024-10-07T11:00', + '2024-10-07T12:00', + '2024-10-07T13:00', + '2024-10-07T14:00', + '2024-10-07T15:00', + '2024-10-07T16:00', + '2024-10-07T17:00', + '2024-10-07T18:00', + '2024-10-07T19:00', + '2024-10-07T20:00', + '2024-10-07T21:00', + '2024-10-07T22:00', + '2024-10-07T23:00', + '2024-10-08T00:00', + '2024-10-08T01:00', + '2024-10-08T02:00', + '2024-10-08T03:00', + '2024-10-08T04:00', + '2024-10-08T05:00', + '2024-10-08T06:00', + '2024-10-08T07:00', + '2024-10-08T08:00', + '2024-10-08T09:00', + '2024-10-08T10:00', + '2024-10-08T11:00', + '2024-10-08T12:00', + '2024-10-08T13:00', + '2024-10-08T14:00', + '2024-10-08T15:00', + '2024-10-08T16:00', + '2024-10-08T17:00', + '2024-10-08T18:00', + '2024-10-08T19:00', + '2024-10-08T20:00', + '2024-10-08T21:00', + '2024-10-08T22:00', + '2024-10-08T23:00', + '2024-10-09T00:00', + '2024-10-09T01:00', + '2024-10-09T02:00', + '2024-10-09T03:00', + '2024-10-09T04:00', + '2024-10-09T05:00', + '2024-10-09T06:00', + '2024-10-09T07:00', + '2024-10-09T08:00', + '2024-10-09T09:00', + '2024-10-09T10:00', + '2024-10-09T11:00', + '2024-10-09T12:00', + '2024-10-09T13:00', + '2024-10-09T14:00', + '2024-10-09T15:00', + '2024-10-09T16:00', + '2024-10-09T17:00', + '2024-10-09T18:00', + '2024-10-09T19:00', + '2024-10-09T20:00', + '2024-10-09T21:00', + '2024-10-09T22:00', + '2024-10-09T23:00', + '2024-10-10T00:00', + '2024-10-10T01:00', + '2024-10-10T02:00', + '2024-10-10T03:00', + '2024-10-10T04:00', + '2024-10-10T05:00', + '2024-10-10T06:00', + '2024-10-10T07:00', + '2024-10-10T08:00', + '2024-10-10T09:00', + '2024-10-10T10:00', + '2024-10-10T11:00', + '2024-10-10T12:00', + '2024-10-10T13:00', + '2024-10-10T14:00', + '2024-10-10T15:00', + '2024-10-10T16:00', + '2024-10-10T17:00', + '2024-10-10T18:00', + '2024-10-10T19:00', + '2024-10-10T20:00', + '2024-10-10T21:00', + '2024-10-10T22:00', + '2024-10-10T23:00', + '2024-10-11T00:00', + '2024-10-11T01:00', + '2024-10-11T02:00', + '2024-10-11T03:00', ], temperature_2m: [ 36.6, 32.8, 29.5, 28.6, 29.2, 28.2, 27.5, 26.6, 26.5, 26, 25, 23.5, 23.9, @@ -168,31 +168,31 @@ const SAMPLE = { ], }, daily_units: { - time: "iso8601", - sunrise: "iso8601", - sunset: "iso8601", + time: 'iso8601', + sunrise: 'iso8601', + sunset: 'iso8601', }, daily: { time: [ - "2024-10-07", - "2024-10-08", - "2024-10-09", - "2024-10-10", - "2024-10-11", + '2024-10-07', + '2024-10-08', + '2024-10-09', + '2024-10-10', + '2024-10-11', ], sunrise: [ - "2024-10-07T07:15", - "2024-10-08T07:16", - "2024-10-09T07:17", - "2024-10-10T07:18", - "2024-10-11T07:19", + '2024-10-07T07:15', + '2024-10-08T07:16', + '2024-10-09T07:17', + '2024-10-10T07:18', + '2024-10-11T07:19', ], sunset: [ - "2024-10-07T19:00", - "2024-10-08T18:58", - "2024-10-09T18:57", - "2024-10-10T18:55", - "2024-10-11T18:54", + '2024-10-07T19:00', + '2024-10-08T18:58', + '2024-10-09T18:57', + '2024-10-10T18:55', + '2024-10-11T18:54', ], }, }; @@ -207,10 +207,10 @@ export function Weather({ weatherAtLocation?: WeatherAtLocation; }) { const currentHigh = Math.max( - ...weatherAtLocation.hourly.temperature_2m.slice(0, 24), + ...weatherAtLocation.hourly.temperature_2m.slice(0, 24) ); const currentLow = Math.min( - ...weatherAtLocation.hourly.temperature_2m.slice(0, 24), + ...weatherAtLocation.hourly.temperature_2m.slice(0, 24) ); const isDay = isWithinInterval(new Date(weatherAtLocation.current.time), { @@ -226,51 +226,51 @@ export function Weather({ }; handleResize(); - window.addEventListener("resize", handleResize); + window.addEventListener('resize', handleResize); - return () => window.removeEventListener("resize", handleResize); + return () => window.removeEventListener('resize', handleResize); }, []); const hoursToShow = isMobile ? 5 : 6; // Find the index of the current time or the next closest time const currentTimeIndex = weatherAtLocation.hourly.time.findIndex( - (time) => new Date(time) >= new Date(weatherAtLocation.current.time), + (time) => new Date(time) >= new Date(weatherAtLocation.current.time) ); // Slice the arrays to get the desired number of items const displayTimes = weatherAtLocation.hourly.time.slice( currentTimeIndex, - currentTimeIndex + hoursToShow, + currentTimeIndex + hoursToShow ); const displayTemperatures = weatherAtLocation.hourly.temperature_2m.slice( currentTimeIndex, - currentTimeIndex + hoursToShow, + currentTimeIndex + hoursToShow ); return (
@@ -286,17 +286,17 @@ export function Weather({ {displayTimes.map((time, index) => (
- {format(new Date(time), "ha")} + {format(new Date(time), 'ha')}
diff --git a/components/ui/button.tsx b/components/ui/button.tsx index 0ba427735..c499e5dcb 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -1,56 +1,56 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; -import { cn } from "@/lib/utils" +import { cn } from '@/lib/utils'; const buttonVariants = cva( - "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + 'inline-flex items-center gap-2 justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', { variants: { variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", + default: 'bg-primary text-primary-foreground hover:bg-primary/90', destructive: - "bg-destructive text-destructive-foreground hover:bg-destructive/90", + 'bg-destructive text-destructive-foreground hover:bg-destructive/90', outline: - "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: "hover:bg-accent hover:text-accent-foreground", - link: "text-primary underline-offset-4 hover:underline", + 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', }, size: { - default: "h-10 px-4 py-2", - sm: "h-9 rounded-md px-3", - lg: "h-11 rounded-md px-8", - icon: "h-10 w-10", + default: 'h-10 px-4 py-2', + sm: 'h-9 rounded-md px-3', + lg: 'h-11 rounded-md px-8', + icon: 'h-10 w-10', }, }, defaultVariants: { - variant: "default", - size: "default", + variant: 'default', + size: 'default', }, } -) +); export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { - asChild?: boolean + asChild?: boolean; } const Button = React.forwardRef( ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button" + const Comp = asChild ? Slot : 'button'; return ( - ) + ); } -) -Button.displayName = "Button" +); +Button.displayName = 'Button'; -export { Button, buttonVariants } +export { Button, buttonVariants }; diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 000000000..afa13ecfa --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx index f69a0d64c..2ceb0c7af 100644 --- a/components/ui/dropdown-menu.tsx +++ b/components/ui/dropdown-menu.tsx @@ -1,34 +1,34 @@ -"use client" +'use client'; -import * as React from "react" -import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" -import { Check, ChevronRight, Circle } from "lucide-react" +import * as React from 'react'; +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { Check, ChevronRight, Circle } from 'lucide-react'; -import { cn } from "@/lib/utils" +import { cn } from '@/lib/utils'; -const DropdownMenu = DropdownMenuPrimitive.Root +const DropdownMenu = DropdownMenuPrimitive.Root; -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; -const DropdownMenuGroup = DropdownMenuPrimitive.Group +const DropdownMenuGroup = DropdownMenuPrimitive.Group; -const DropdownMenuPortal = DropdownMenuPrimitive.Portal +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; -const DropdownMenuSub = DropdownMenuPrimitive.Sub +const DropdownMenuSub = DropdownMenuPrimitive.Sub; -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; const DropdownMenuSubTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - inset?: boolean + inset?: boolean; } >(({ className, inset, children, ...props }, ref) => ( -)) +)); DropdownMenuSubTrigger.displayName = - DropdownMenuPrimitive.SubTrigger.displayName + DropdownMenuPrimitive.SubTrigger.displayName; const DropdownMenuSubContent = React.forwardRef< React.ElementRef, @@ -47,14 +47,14 @@ const DropdownMenuSubContent = React.forwardRef< -)) +)); DropdownMenuSubContent.displayName = - DropdownMenuPrimitive.SubContent.displayName + DropdownMenuPrimitive.SubContent.displayName; const DropdownMenuContent = React.forwardRef< React.ElementRef, @@ -65,32 +65,32 @@ const DropdownMenuContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + 'z-50 min-w-[8rem] overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', className )} {...props} /> -)) -DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; const DropdownMenuItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - inset?: boolean + inset?: boolean; } >(({ className, inset, ...props }, ref) => ( -)) -DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; const DropdownMenuCheckboxItem = React.forwardRef< React.ElementRef, @@ -99,7 +99,7 @@ const DropdownMenuCheckboxItem = React.forwardRef< {children} -)) +)); DropdownMenuCheckboxItem.displayName = - DropdownMenuPrimitive.CheckboxItem.displayName + DropdownMenuPrimitive.CheckboxItem.displayName; const DropdownMenuRadioItem = React.forwardRef< React.ElementRef, @@ -123,7 +123,7 @@ const DropdownMenuRadioItem = React.forwardRef< {children} -)) -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; const DropdownMenuLabel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - inset?: boolean + inset?: boolean; } >(({ className, inset, ...props }, ref) => ( -)) -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; const DropdownMenuSeparator = React.forwardRef< React.ElementRef, @@ -162,11 +162,11 @@ const DropdownMenuSeparator = React.forwardRef< >(({ className, ...props }, ref) => ( -)) -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; const DropdownMenuShortcut = ({ className, @@ -174,12 +174,12 @@ const DropdownMenuShortcut = ({ }: React.HTMLAttributes) => { return ( - ) -} -DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + ); +}; +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; export { DropdownMenu, @@ -197,4 +197,4 @@ export { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuRadioGroup, -} +}; diff --git a/components/ui/select.tsx b/components/ui/select.tsx new file mode 100644 index 000000000..cbe5a36b6 --- /dev/null +++ b/components/ui/select.tsx @@ -0,0 +1,160 @@ +"use client" + +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx new file mode 100644 index 000000000..12d81c4a8 --- /dev/null +++ b/components/ui/separator.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx new file mode 100644 index 000000000..6fd08de0b --- /dev/null +++ b/components/ui/sidebar.tsx @@ -0,0 +1,773 @@ +'use client'; + +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { VariantProps, cva } from 'class-variance-authority'; +import { PanelLeft } from 'lucide-react'; + +import { useIsMobile } from '@/hooks/use-mobile'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetTitle, +} from '@/components/ui/sheet'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +const SIDEBAR_COOKIE_NAME = 'sidebar:state'; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const SIDEBAR_WIDTH = '16rem'; +const SIDEBAR_WIDTH_MOBILE = '18rem'; +const SIDEBAR_WIDTH_ICON = '3rem'; +const SIDEBAR_KEYBOARD_SHORTCUT = 'b'; + +type SidebarContext = { + state: 'expanded' | 'collapsed'; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +const SidebarContext = React.createContext(null); + +function useSidebar() { + const context = React.useContext(SidebarContext); + if (!context) { + throw new Error('useSidebar must be used within a SidebarProvider.'); + } + + return context; +} + +const SidebarProvider = React.forwardRef< + HTMLDivElement, + React.ComponentProps<'div'> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; + } +>( + ( + { + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props + }, + ref + ) => { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen); + const open = openProp ?? _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + if (setOpenProp) { + return setOpenProp?.( + typeof value === 'function' ? value(open) : value + ); + } + + _setOpen(value); + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + }, + [setOpenProp, open] + ); + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile + ? setOpenMobile((open) => !open) + : setOpen((open) => !open); + }, [isMobile, setOpen, setOpenMobile]); + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [toggleSidebar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? 'expanded' : 'collapsed'; + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ); + + return ( + + +
+ {children} +
+
+
+ ); + } +); +SidebarProvider.displayName = 'SidebarProvider'; + +const Sidebar = React.forwardRef< + HTMLDivElement, + React.ComponentProps<'div'> & { + side?: 'left' | 'right'; + variant?: 'sidebar' | 'floating' | 'inset'; + collapsible?: 'offcanvas' | 'icon' | 'none'; + } +>( + ( + { + side = 'left', + variant = 'sidebar', + collapsible = 'offcanvas', + className, + children, + ...props + }, + ref + ) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + + if (collapsible === 'none') { + return ( +
+ {children} +
+ ); + } + + if (isMobile) { + return ( + + Sidebar + + Mobile sidebar + + +
{children}
+
+
+ ); + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ); + } +); +Sidebar.displayName = 'Sidebar'; + +const SidebarTrigger = React.forwardRef< + React.ElementRef, + React.ComponentProps +>(({ onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar(); + + return ( + + ); +}); +SidebarTrigger.displayName = 'SidebarTrigger'; + +const SidebarRail = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<'button'> +>(({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar(); + + return ( +