Start on new sidebar (#456)

Co-authored-by: shadcn <m@shadcn.com>
This commit is contained in:
Jared Palmer 2024-10-24 16:35:51 -04:00 committed by GitHub
parent 7faa5f1c9f
commit a68eb2a011
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 2350 additions and 800 deletions

View file

@ -1,8 +1,13 @@
import { openai } from "@ai-sdk/openai"; import { openai } from '@ai-sdk/openai';
import { experimental_wrapLanguageModel as wrapLanguageModel } from "ai"; import { experimental_wrapLanguageModel as wrapLanguageModel } from 'ai';
import { customMiddleware } from "./custom-middleware";
export const customModel = wrapLanguageModel({ import { type Model } from '@/lib/model';
model: openai("gpt-4o"),
import { customMiddleware } from './custom-middleware';
export const customModel = (modelName: Model['name']) => {
return wrapLanguageModel({
model: openai(modelName),
middleware: customMiddleware, middleware: customMiddleware,
}); });
};

8
app/(chat)/actions.ts Normal file
View file

@ -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);
}

View file

@ -1,38 +1,47 @@
import { convertToCoreMessages, Message, streamText } from "ai"; import { convertToCoreMessages, Message, streamText } from 'ai';
import { z } from "zod"; import { z } from 'zod';
import { customModel } from "@/ai"; import { customModel } from '@/ai';
import { auth } from "@/app/(auth)/auth"; import { auth } from '@/app/(auth)/auth';
import { deleteChatById, getChatById, saveChat } from "@/db/queries"; import { deleteChatById, getChatById, saveChat } from '@/db/queries';
import { Model, models } from '@/lib/model';
export async function POST(request: Request) { export async function POST(request: Request) {
const { id, messages }: { id: string; messages: Array<Message> } = const {
id,
messages,
model,
}: { id: string; messages: Array<Message>; model: Model['name'] } =
await request.json(); await request.json();
const session = await auth(); const session = await auth();
if (!session) { 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 coreMessages = convertToCoreMessages(messages);
const result = await streamText({ const result = await streamText({
model: customModel, model: customModel(model),
system: 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, messages: coreMessages,
maxSteps: 5, maxSteps: 5,
tools: { tools: {
getWeather: { getWeather: {
description: "Get the current weather at a location", description: 'Get the current weather at a location',
parameters: z.object({ parameters: z.object({
latitude: z.number(), latitude: z.number(),
longitude: z.number(), longitude: z.number(),
}), }),
execute: async ({ latitude, longitude }) => { execute: async ({ latitude, longitude }) => {
const response = await fetch( const response = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&current=temperature_2m&hourly=temperature_2m&daily=sunrise,sunset&timezone=auto`, `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&current=temperature_2m&hourly=temperature_2m&daily=sunrise,sunset&timezone=auto`
); );
const weatherData = await response.json(); const weatherData = await response.json();
@ -49,13 +58,13 @@ export async function POST(request: Request) {
userId: session.user.id, userId: session.user.id,
}); });
} catch (error) { } catch (error) {
console.error("Failed to save chat"); console.error('Failed to save chat');
} }
} }
}, },
experimental_telemetry: { experimental_telemetry: {
isEnabled: true, isEnabled: true,
functionId: "stream-text", functionId: 'stream-text',
}, },
}); });
@ -64,30 +73,30 @@ export async function POST(request: Request) {
export async function DELETE(request: Request) { export async function DELETE(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const id = searchParams.get("id"); const id = searchParams.get('id');
if (!id) { if (!id) {
return new Response("Not Found", { status: 404 }); return new Response('Not Found', { status: 404 });
} }
const session = await auth(); const session = await auth();
if (!session || !session.user) { if (!session || !session.user) {
return new Response("Unauthorized", { status: 401 }); return new Response('Unauthorized', { status: 401 });
} }
try { try {
const chat = await getChatById({ id }); const chat = await getChatById({ id });
if (chat.userId !== session.user.id) { if (chat.userId !== session.user.id) {
return new Response("Unauthorized", { status: 401 }); return new Response('Unauthorized', { status: 401 });
} }
await deleteChatById({ id }); await deleteChatById({ id });
return new Response("Chat deleted", { status: 200 }); return new Response('Chat deleted', { status: 200 });
} catch (error) { } catch (error) {
return new Response("An error occurred while processing your request", { return new Response('An error occurred while processing your request', {
status: 500, status: 500,
}); });
} }

View file

@ -1,11 +1,13 @@
import { CoreMessage } from "ai"; import { CoreMessage } from 'ai';
import { notFound } from "next/navigation"; import { cookies } from 'next/headers';
import { notFound } from 'next/navigation';
import { auth } from "@/app/(auth)/auth"; import { auth } from '@/app/(auth)/auth';
import { Chat as PreviewChat } from "@/components/custom/chat"; import { Chat as PreviewChat } from '@/components/custom/chat';
import { getChatById } from "@/db/queries"; import { getChatById } from '@/db/queries';
import { Chat } from "@/db/schema"; import { Chat } from '@/db/schema';
import { convertToUIMessages, generateUUID } from "@/lib/utils"; import { DEFAULT_MODEL_NAME, models } from '@/lib/model';
import { convertToUIMessages } from '@/lib/utils';
export default async function Page(props: { params: Promise<any> }) { export default async function Page(props: { params: Promise<any> }) {
const params = await props.params; const params = await props.params;
@ -32,5 +34,16 @@ export default async function Page(props: { params: Promise<any> }) {
return notFound(); return notFound();
} }
return <PreviewChat id={chat.id} initialMessages={chat.messages} />; const cookieStore = await cookies();
const value = cookieStore.get('model')?.value;
const selectedModelName =
models.find((m) => m.name === value)?.name || DEFAULT_MODEL_NAME;
return (
<PreviewChat
id={chat.id}
initialMessages={chat.messages}
selectedModelName={selectedModelName}
/>
);
} }

22
app/(chat)/layout.tsx Normal file
View file

@ -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 (
<SidebarProvider defaultOpen={!isCollapsed}>
<AppSidebar user={session?.user} />
<SidebarInset>{children}</SidebarInset>
</SidebarProvider>
);
}

View file

@ -1,7 +1,23 @@
import { Chat } from "@/components/custom/chat"; import { cookies } from 'next/headers';
import { generateUUID } from "@/lib/utils";
import { Chat } from '@/components/custom/chat';
import { DEFAULT_MODEL_NAME, models } from '@/lib/model';
import { generateUUID } from '@/lib/utils';
export default async function Page() { export default async function Page() {
const id = generateUUID(); const id = generateUUID();
return <Chat key={id} id={id} initialMessages={[]} />;
const cookieStore = await cookies();
const value = cookieStore.get('model')?.value;
const selectedModelName =
models.find((m) => m.name === value)?.name || DEFAULT_MODEL_NAME;
return (
<Chat
key={id}
id={id}
initialMessages={[]}
selectedModelName={selectedModelName}
/>
);
} }

View file

@ -49,6 +49,14 @@
--chart-4: 43 74% 66%; --chart-4: 43 74% 66%;
--chart-5: 27 87% 67%; --chart-5: 27 87% 67%;
--radius: 0.5rem; --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 { .dark {
--background: 240 10% 3.9%; --background: 240 10% 3.9%;
@ -75,6 +83,14 @@
--chart-3: 30 80% 55%; --chart-3: 30 80% 55%;
--chart-4: 280 65% 60%; --chart-4: 280 65% 60%;
--chart-5: 340 75% 55%; --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-face {
font-family: "geist"; font-family: 'geist';
font-style: normal; font-style: normal;
font-weight: 100 900; font-weight: 100 900;
src: url(/fonts/geist.woff2) format("woff2"); src: url(/fonts/geist.woff2) format('woff2');
} }
@font-face { @font-face {
font-family: "geist-mono"; font-family: 'geist-mono';
font-style: normal; font-style: normal;
font-weight: 100 900; 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; pointer-events: none !important;
} }
*[class^="text-"] { *[class^='text-'] {
color: transparent; color: transparent;
@apply rounded-md bg-foreground/20 select-none animate-pulse; @apply rounded-md bg-foreground/20 select-none animate-pulse;
} }

View file

@ -1,7 +1,6 @@
import { Metadata } from 'next'; import { Metadata } from 'next';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import { Navbar } from '@/components/custom/navbar';
import { ThemeProvider } from '@/components/custom/theme-provider'; import { ThemeProvider } from '@/components/custom/theme-provider';
import './globals.css'; import './globals.css';
@ -65,7 +64,6 @@ export default async function RootLayout({
disableTransitionOnChange disableTransitionOnChange
> >
<Toaster position="top-center" /> <Toaster position="top-center" />
<Navbar />
{children} {children}
</ThemeProvider> </ThemeProvider>
</body> </body>

View file

@ -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 (
<Sidebar className="group-data-[side=left]:border-r-0">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Link href="/" onClick={() => setOpenMobile(false)}>
<span className="text-lg font-semibold font-mono tracking-tighter">
Chatbot
</span>
</Link>
</SidebarMenuButton>
<BetterTooltip content="New Chat">
<SidebarMenuAction asChild>
<Link href="/" onClick={() => setOpenMobile(false)}>
<Plus />
</Link>
</SidebarMenuAction>
</BetterTooltip>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarHistory user={user} />
</SidebarContent>
<SidebarFooter className="gap-0">
<SidebarGroup>
<SidebarGroupContent>
<Card className="p-4 flex flex-col gap-4 relative rounded-md border-none shadow-none hover:shadow transition-shadow">
<a
href="https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fai-chatbot&env=AUTH_SECRET,OPENAI_API_KEY&envDescription=Learn%20more%20about%20how%20to%20get%20the%20API%20Keys%20for%20the%20application&envLink=https%3A%2F%2Fgithub.com%2Fvercel%2Fai-chatbot%2Fblob%2Fmain%2F.env.example&demo-title=AI%20Chatbot&demo-description=An%20Open-Source%20AI%20Chatbot%20Template%20Built%20With%20Next.js%20and%20the%20AI%20SDK%20by%20Vercel.&demo-url=https%3A%2F%2Fchat.vercel.ai&stores=[{%22type%22:%22postgres%22},{%22type%22:%22blob%22}]"
className="absolute inset-0 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
target="_blank"
rel="noopener noreferrer"
>
<span className="sr-only">Deploy</span>
</a>
<CardHeader className="p-0">
<CardTitle className="text-base">Deploy your own</CardTitle>
<CardDescription className="text-sm">
Open Source Chatbot template built with Next.js and the AI SDK
by Vercel.
</CardDescription>
</CardHeader>
<CardFooter className="p-0">
<Button size="sm" className="w-full h-8 py-0 justify-start">
<VercelIcon size={16} />
Deploy with Vercel
</Button>
</CardFooter>
</Card>
</SidebarGroupContent>
</SidebarGroup>
{user && (
<SidebarGroup>
<SidebarGroupContent>
<SidebarUserNav user={user} />
</SidebarGroupContent>
</SidebarGroup>
)}
</SidebarFooter>
</Sidebar>
);
}

View file

@ -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 (
<header className="flex h-16 sticky top-0 bg-background md:h-12 items-center px-2 md:px-2 z-10">
<SidebarToggle />
<BetterTooltip content="New Chat">
<Button
variant="ghost"
className="w-auto md:size-8 [&>svg]:!size-5 md:[&>svg]:!size-4 pl-2 md:p-0 order-2 md:order-1 ml-auto md:ml-0 md:hidden group-data-[state=collapsed]/sidebar-wrapper:flex"
asChild
>
<Link href="/">
<Plus />
<span className="md:sr-only">New Chat</span>
</Link>
</Button>
</BetterTooltip>
<ModelSelector
selectedModelName={selectedModelName}
className="order-1 md:order-2"
/>
</header>
);
}

View file

@ -1,28 +1,32 @@
"use client"; 'use client';
import { Attachment, Message } from "ai"; import { Attachment, Message } from 'ai';
import { useChat } from "ai/react"; import { useChat } from 'ai/react';
import { useState } from "react"; import { useState } from 'react';
import { Message as PreviewMessage } from "@/components/custom/message"; import { ChatHeader } from '@/components/custom/chat-header';
import { useScrollToBottom } from "@/components/custom/use-scroll-to-bottom"; 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 { MultimodalInput } from './multimodal-input';
import { Overview } from "./overview"; import { Overview } from './overview';
export function Chat({ export function Chat({
id, id,
initialMessages, initialMessages,
selectedModelName,
}: { }: {
id: string; id: string;
initialMessages: Array<Message>; initialMessages: Array<Message>;
selectedModelName: Model['name'];
}) { }) {
const { messages, handleSubmit, input, setInput, append, isLoading, stop } = const { messages, handleSubmit, input, setInput, append, isLoading, stop } =
useChat({ useChat({
body: { id }, body: { id, model: selectedModelName },
initialMessages, initialMessages,
onFinish: () => { onFinish: () => {
window.history.replaceState({}, "", `/chat/${id}`); window.history.replaceState({}, '', `/chat/${id}`);
}, },
}); });
@ -32,11 +36,11 @@ export function Chat({
const [attachments, setAttachments] = useState<Array<Attachment>>([]); const [attachments, setAttachments] = useState<Array<Attachment>>([]);
return ( return (
<div className="flex flex-row justify-center pb-4 md:pb-8 h-dvh bg-background"> <div className="flex flex-col min-w-0 h-dvh bg-background">
<div className="flex flex-col justify-between items-center gap-4"> <ChatHeader selectedModelName={selectedModelName} />
<div <div
ref={messagesContainerRef} ref={messagesContainerRef}
className="flex flex-col gap-4 h-full w-dvw items-center overflow-y-scroll" className="flex flex-col min-w-0 gap-6 flex-1 overflow-y-scroll"
> >
{messages.length === 0 && <Overview />} {messages.length === 0 && <Overview />}
@ -55,8 +59,7 @@ export function Chat({
className="shrink-0 min-w-[24px] min-h-[24px]" className="shrink-0 min-w-[24px] min-h-[24px]"
/> />
</div> </div>
<form className="flex mx-auto px-4 bg-background pb-4 md:pb-6 gap-2 w-full md:max-w-3xl">
<form className="flex flex-row gap-2 relative items-end w-full md:max-w-[500px] max-w-[calc(100dvw-32px) px-4 md:px-0">
<MultimodalInput <MultimodalInput
input={input} input={input}
setInput={setInput} setInput={setInput}
@ -70,6 +73,5 @@ export function Chat({
/> />
</form> </form>
</div> </div>
</div>
); );
} }

View file

@ -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<Array<Chat>>(user ? "/api/history" : null, fetcher, {
fallbackData: [],
});
useEffect(() => {
mutate();
}, [pathname, mutate]);
const [deleteId, setDeleteId] = useState<string | null>(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 (
<>
<Button
variant="outline"
className="p-1.5 h-fit"
onClick={() => {
setIsHistoryVisible(true);
}}
>
<MenuIcon />
</Button>
<Sheet
open={isHistoryVisible}
onOpenChange={(state) => {
setIsHistoryVisible(state);
}}
>
<SheetContent side="left" className="p-3 w-80 bg-muted">
<SheetHeader>
<VisuallyHidden.Root>
<SheetTitle className="text-left">History</SheetTitle>
<SheetDescription className="text-left">
{history === undefined ? "loading" : history.length} chats
</SheetDescription>
</VisuallyHidden.Root>
</SheetHeader>
<div className="text-sm flex flex-row items-center justify-between">
<div className="flex flex-row gap-2">
<div className="dark:text-zinc-300">History</div>
<div className="dark:text-zinc-400 text-zinc-500">
{history === undefined ? "loading" : history.length} chats
</div>
</div>
</div>
<div className="mt-10 flex flex-col">
{user && (
<Button
className="font-normal text-sm flex flex-row justify-between"
asChild
>
<Link href="/">
<div>Start a new chat</div>
<PencilEditIcon size={14} />
</Link>
</Button>
)}
<div className="flex flex-col overflow-y-scroll p-1 h-[calc(100dvh-124px)]">
{!user ? (
<div className="text-zinc-500 h-dvh w-full flex flex-row justify-center items-center text-sm gap-2">
<InfoIcon />
<div>Login to save and revisit previous chats!</div>
</div>
) : null}
{!isLoading && history?.length === 0 && user ? (
<div className="text-zinc-500 h-dvh w-full flex flex-row justify-center items-center text-sm gap-2">
<InfoIcon />
<div>No chats found</div>
</div>
) : null}
{isLoading && user ? (
<div className="flex flex-col">
{[44, 32, 28, 52].map((item) => (
<div key={item} className="p-2 my-[2px]">
<div
className={`w-${item} h-[20px] rounded-md bg-zinc-200 dark:bg-zinc-600 animate-pulse`}
/>
</div>
))}
</div>
) : null}
{history &&
history.map((chat) => (
<div
key={chat.id}
className={cx(
"flex flex-row items-center gap-6 hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-md pr-2",
{ "bg-zinc-200 dark:bg-zinc-700": chat.id === id },
)}
>
<Button
variant="ghost"
className={cx(
"hover:bg-zinc-200 dark:hover:bg-zinc-700 justify-between p-0 text-sm font-normal flex flex-row items-center gap-2 pr-2 w-full transition-none",
)}
asChild
>
<Link
href={`/chat/${chat.id}`}
className="text-ellipsis overflow-hidden text-left py-2 pl-2 rounded-lg outline-zinc-900"
>
{getTitleFromChat(chat)}
</Link>
</Button>
<DropdownMenu modal={true}>
<DropdownMenuTrigger asChild>
<Button
className="p-0 h-fit font-normal text-zinc-500 transition-none hover:bg-zinc-200 dark:hover:bg-zinc-700"
variant="ghost"
>
<MoreHorizontalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="left" className="z-[60]">
<DropdownMenuItem asChild>
<Button
className="flex flex-row gap-2 items-center justify-start w-full h-fit font-normal p-1.5 rounded-sm"
variant="ghost"
onClick={() => {
setDeleteId(chat.id);
setShowDeleteDialog(true);
}}
>
<TrashIcon />
<div>Delete</div>
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
</div>
</SheetContent>
</Sheet>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete your
chat and remove it from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>
Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};

View file

@ -1,13 +1,13 @@
"use client"; 'use client';
import { Attachment, ToolInvocation } from "ai"; import { Attachment, ToolInvocation } from 'ai';
import { motion } from "framer-motion"; import { motion } from 'framer-motion';
import { ReactNode } from "react"; import { Sparkles } from 'lucide-react';
import { ReactNode } from 'react';
import { BotIcon, UserIcon } from "./icons"; import { Markdown } from './markdown';
import { Markdown } from "./markdown"; import { PreviewAttachment } from './preview-attachment';
import { PreviewAttachment } from "./preview-attachment"; import { Weather } from './weather';
import { Weather } from "./weather";
export const Message = ({ export const Message = ({
role, role,
@ -22,32 +22,35 @@ export const Message = ({
}) => { }) => {
return ( return (
<motion.div <motion.div
className={`flex flex-row gap-4 px-4 w-full md:w-[500px] md:px-0 first-of-type:pt-20`} className="w-full mx-auto max-w-3xl px-4 group/message "
initial={{ y: 5, opacity: 0 }} initial={{ y: 5, opacity: 0 }}
animate={{ y: 0, opacity: 1 }} animate={{ y: 0, opacity: 1 }}
data-role={role}
> >
<div className="size-[24px] flex flex-col justify-center items-center shrink-0 text-zinc-400"> <div className="flex gap-4 group-data-[role=user]/message:px-5 w-full group-data-[role=user]/message:w-fit group-data-[role=user]/message:ml-auto group-data-[role=user]/message:max-w-2xl group-data-[role=user]/message:py-3.5 group-data-[role=user]/message:bg-muted rounded-xl">
{role === "assistant" ? <BotIcon /> : <UserIcon />} {role === 'assistant' && (
<div className="size-8 flex items-center rounded-full justify-center ring-1 shrink-0 ring-border">
<Sparkles className="size-4" />
</div> </div>
)}
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
{content && ( {content && (
<div className="text-zinc-800 dark:text-zinc-300 flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Markdown>{content as string}</Markdown> <Markdown>{content as string}</Markdown>
</div> </div>
)} )}
{toolInvocations && ( {toolInvocations && toolInvocations.length > 0 ? (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{toolInvocations.map((toolInvocation) => { {toolInvocations.map((toolInvocation) => {
const { toolName, toolCallId, state } = toolInvocation; const { toolName, toolCallId, state } = toolInvocation;
if (state === "result") { if (state === 'result') {
const { result } = toolInvocation; const { result } = toolInvocation;
return ( return (
<div key={toolCallId}> <div key={toolCallId}>
{toolName === "getWeather" ? ( {toolName === 'getWeather' ? (
<Weather weatherAtLocation={result} /> <Weather weatherAtLocation={result} />
) : null} ) : null}
</div> </div>
@ -55,22 +58,26 @@ export const Message = ({
} else { } else {
return ( return (
<div key={toolCallId} className="skeleton"> <div key={toolCallId} className="skeleton">
{toolName === "getWeather" ? <Weather /> : null} {toolName === 'getWeather' ? <Weather /> : null}
</div> </div>
); );
} }
})} })}
</div> </div>
)} ) : null}
{attachments && ( {attachments && (
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
{attachments.map((attachment) => ( {attachments.map((attachment) => (
<PreviewAttachment key={attachment.url} attachment={attachment} /> <PreviewAttachment
key={attachment.url}
attachment={attachment}
/>
))} ))}
</div> </div>
)} )}
</div> </div>
</div>
</motion.div> </motion.div>
); );
}; };

View file

@ -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<typeof Button>) {
const [open, setOpen] = useState(false);
const [optimisticModelName, setOptimisticModelName] =
useOptimistic(selectedModelName);
const selectModel = useMemo(
() => models.find((model) => model.name === optimisticModelName),
[optimisticModelName]
);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
asChild
className={cn(
'w-fit data-[state=open]:bg-accent data-[state=open]:text-accent-foreground md:h-8 [&>svg]:!size-5 md:[&>svg]:!size-4',
className
)}
>
<Button variant="ghost">
{selectModel?.label}
<ChevronDown className="text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[300px]">
{models.map((model) => (
<DropdownMenuItem
key={model.name}
onSelect={() => {
setOpen(false);
startTransition(() => {
setOptimisticModelName(model.name);
saveModel(model.name);
});
}}
className="gap-4 group/item"
data-active={model.name === optimisticModelName}
>
<div className="flex flex-col gap-1 items-start">
{model.label}
{model.description && (
<div className="text-xs text-muted-foreground">
{model.description}
</div>
)}
</div>
<Check className="size-4 ml-auto opacity-0 group-data-[active=true]/item:opacity-100" />
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -1,7 +1,7 @@
"use client"; 'use client';
import { Attachment, ChatRequestOptions, CreateMessage, Message } from "ai"; import { Attachment, ChatRequestOptions, CreateMessage, Message } from 'ai';
import { motion } from "framer-motion"; import { motion } from 'framer-motion';
import React, { import React, {
useRef, useRef,
useEffect, useEffect,
@ -10,24 +10,24 @@ import React, {
Dispatch, Dispatch,
SetStateAction, SetStateAction,
ChangeEvent, ChangeEvent,
} from "react"; } from 'react';
import { toast } from "sonner"; import { toast } from 'sonner';
import { ArrowUpIcon, PaperclipIcon, StopIcon } from "./icons"; import { ArrowUpIcon, PaperclipIcon, StopIcon } from './icons';
import { PreviewAttachment } from "./preview-attachment"; import { PreviewAttachment } from './preview-attachment';
import useWindowSize from "./use-window-size"; import useWindowSize from './use-window-size';
import { Button } from "../ui/button"; import { Button } from '../ui/button';
import { Textarea } from "../ui/textarea"; import { Textarea } from '../ui/textarea';
const suggestedActions = [ const suggestedActions = [
{ {
title: "What is the weather", title: 'What is the weather',
label: "in San Francisco?", label: 'in San Francisco?',
action: "What is the weather in San Francisco?", action: 'What is the weather in San Francisco?',
}, },
{ {
title: "Answer like I'm 5,", 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?", action: "Answer like I'm 5, why is the sky blue?",
}, },
]; ];
@ -52,13 +52,13 @@ export function MultimodalInput({
messages: Array<Message>; messages: Array<Message>;
append: ( append: (
message: Message | CreateMessage, message: Message | CreateMessage,
chatRequestOptions?: ChatRequestOptions, chatRequestOptions?: ChatRequestOptions
) => Promise<string | null | undefined>; ) => Promise<string | null | undefined>;
handleSubmit: ( handleSubmit: (
event?: { event?: {
preventDefault?: () => void; preventDefault?: () => void;
}, },
chatRequestOptions?: ChatRequestOptions, chatRequestOptions?: ChatRequestOptions
) => void; ) => void;
}) { }) {
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -72,7 +72,7 @@ export function MultimodalInput({
const adjustHeight = () => { const adjustHeight = () => {
if (textareaRef.current) { if (textareaRef.current) {
textareaRef.current.style.height = "auto"; textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`; textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`;
} }
}; };
@ -99,11 +99,11 @@ export function MultimodalInput({
const uploadFile = async (file: File) => { const uploadFile = async (file: File) => {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append('file', file);
try { try {
const response = await fetch(`/api/files/upload`, { const response = await fetch(`/api/files/upload`, {
method: "POST", method: 'POST',
body: formData, body: formData,
}); });
@ -121,7 +121,7 @@ export function MultimodalInput({
toast.error(error); toast.error(error);
} }
} catch (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 uploadPromises = files.map((file) => uploadFile(file));
const uploadedAttachments = await Promise.all(uploadPromises); const uploadedAttachments = await Promise.all(uploadPromises);
const successfullyUploadedAttachments = uploadedAttachments.filter( const successfullyUploadedAttachments = uploadedAttachments.filter(
(attachment) => attachment !== undefined, (attachment) => attachment !== undefined
); );
setAttachments((currentAttachments) => [ setAttachments((currentAttachments) => [
@ -143,12 +143,12 @@ export function MultimodalInput({
...successfullyUploadedAttachments, ...successfullyUploadedAttachments,
]); ]);
} catch (error) { } catch (error) {
console.error("Error uploading files!", error); console.error('Error uploading files!', error);
} finally { } finally {
setUploadQueue([]); setUploadQueue([]);
} }
}, },
[setAttachments], [setAttachments]
); );
return ( return (
@ -156,7 +156,7 @@ export function MultimodalInput({
{messages.length === 0 && {messages.length === 0 &&
attachments.length === 0 && attachments.length === 0 &&
uploadQueue.length === 0 && ( uploadQueue.length === 0 && (
<div className="grid sm:grid-cols-2 gap-2 w-full md:px-0 mx-auto md:max-w-[500px]"> <div className="grid sm:grid-cols-2 gap-2 w-full">
{suggestedActions.map((suggestedAction, index) => ( {suggestedActions.map((suggestedAction, index) => (
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
@ -164,22 +164,23 @@ export function MultimodalInput({
exit={{ opacity: 0, y: 20 }} exit={{ opacity: 0, y: 20 }}
transition={{ delay: 0.05 * index }} transition={{ delay: 0.05 * index }}
key={index} key={index}
className={index > 1 ? "hidden sm:block" : "block"} className={index > 1 ? 'hidden sm:block' : 'block'}
> >
<button <Button
variant="ghost"
onClick={async () => { onClick={async () => {
append({ append({
role: "user", role: 'user',
content: suggestedAction.action, content: suggestedAction.action,
}); });
}} }}
className="w-full text-left border border-zinc-200 dark:border-zinc-800 text-zinc-800 dark:text-zinc-300 rounded-lg p-2 text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors flex flex-col" className="text-left border rounded-xl px-4 py-3.5 text-sm flex-1 gap-1 sm:flex-col w-full h-auto justify-start items-start"
> >
<span className="font-medium">{suggestedAction.title}</span> <span className="font-medium">{suggestedAction.title}</span>
<span className="text-zinc-500 dark:text-zinc-400"> <span className="text-muted-foreground">
{suggestedAction.label} {suggestedAction.label}
</span> </span>
</button> </Button>
</motion.div> </motion.div>
))} ))}
</div> </div>
@ -204,9 +205,9 @@ export function MultimodalInput({
<PreviewAttachment <PreviewAttachment
key={filename} key={filename}
attachment={{ attachment={{
url: "", url: '',
name: filename, name: filename,
contentType: "", contentType: '',
}} }}
isUploading={true} isUploading={true}
/> />
@ -219,14 +220,14 @@ export function MultimodalInput({
placeholder="Send a message..." placeholder="Send a message..."
value={input} value={input}
onChange={handleInput} 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} rows={3}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) { if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault(); event.preventDefault();
if (isLoading) { 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 { } else {
submitForm(); submitForm();
} }

View file

@ -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 (
<>
<div className="bg-background absolute top-0 left-0 w-dvw py-2 px-3 justify-between flex flex-row items-center z-30">
<div className="flex flex-row gap-3 items-center">
<History user={session?.user} />
<div className="flex flex-row gap-2 items-center">
<div className="text-sm dark:text-zinc-300">Next.js Chatbot</div>
</div>
</div>
{session ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="py-1.5 px-2 h-fit font-normal"
variant="secondary"
>
{session.user?.email}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<ThemeToggle />
</DropdownMenuItem>
<DropdownMenuItem className="p-1 z-50">
<Form
className="w-full"
action={async () => {
'use server';
await signOut({
redirectTo: '/',
});
}}
>
<button
type="submit"
className="w-full text-left px-1 py-0.5 text-red-500"
>
Sign out
</button>
</Form>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button className="py-1.5 px-2 h-fit font-normal" asChild>
<Link href="/login">Login</Link>
</Button>
)}
</div>
</>
);
};

View file

@ -1,41 +1,40 @@
import { motion } from "framer-motion"; import { motion } from 'framer-motion';
import Link from "next/link"; import Link from 'next/link';
import { LogoOpenAI, MessageIcon, VercelIcon } from "./icons"; import { MessageIcon, VercelIcon } from './icons';
export const Overview = () => { export const Overview = () => {
return ( return (
<motion.div <motion.div
key="overview" key="overview"
className="max-w-[500px] mt-20 mx-4 md:mx-0" className="max-w-3xl mx-auto md:mt-20"
initial={{ opacity: 0, scale: 0.98 }} initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }} exit={{ opacity: 0, scale: 0.98 }}
transition={{ delay: 0.5 }} transition={{ delay: 0.5 }}
> >
<div className="border rounded-lg p-6 flex flex-col gap-4 text-zinc-500 text-sm dark:text-zinc-400 dark:border-zinc-700"> <div className="rounded-xl p-6 flex flex-col gap-8 leading-relaxed text-center max-w-xl">
<p className="flex flex-row justify-center gap-4 items-center text-zinc-900 dark:text-zinc-50"> <p className="flex flex-row justify-center gap-4 items-center">
<VercelIcon /> <VercelIcon size={32} />
<span>+</span> <span>+</span>
<MessageIcon /> <MessageIcon size={32} />
</p> </p>
<p> <p>
This is an open source Chatbot template built with Next.js and the AI This is an open source Chatbot template built with Next.js and the AI
SDK by Vercel. It uses the{" "} SDK by Vercel. It uses the{' '}
<code className="rounded-md bg-muted px-1 py-0.5">streamText</code>{" "} <code className="rounded-md bg-muted px-1 py-0.5">streamText</code>{' '}
function in the server and the{" "} function in the server and the{' '}
<code className="rounded-md bg-muted px-1 py-0.5">useChat</code> hook <code className="rounded-md bg-muted px-1 py-0.5">useChat</code> hook
on the client to create a seamless chat experience. on the client to create a seamless chat experience.
</p> </p>
<p> <p>
{" "} You can learn more about the AI SDK by visiting the{' '}
You can learn more about the AI SDK by visiting the{" "}
<Link <Link
className="text-blue-500 dark:text-blue-400" className="font-medium underline underline-offset-4"
href="https://sdk.vercel.ai/docs" href="https://sdk.vercel.ai/docs"
target="_blank" target="_blank"
> >
Docs docs
</Link> </Link>
. .
</p> </p>

View file

@ -1,6 +1,6 @@
import { Attachment } from "ai"; import { Attachment } from 'ai';
import { LoaderIcon } from "./icons"; import { LoaderIcon } from './icons';
export const PreviewAttachment = ({ export const PreviewAttachment = ({
attachment, attachment,
@ -12,18 +12,18 @@ export const PreviewAttachment = ({
const { name, url, contentType } = attachment; const { name, url, contentType } = attachment;
return ( return (
(<div className="flex flex-col gap-2 max-w-16"> <div className="flex flex-col gap-2 max-w-16">
<div className="h-20 w-16 bg-muted rounded-md relative flex flex-col items-center justify-center"> <div className="w-20 aspect-video bg-muted rounded-md relative flex flex-col items-center justify-center">
{contentType ? ( {contentType ? (
contentType.startsWith("image") ? ( contentType.startsWith('image') ? (
// NOTE: it is recommended to use next/image for images // NOTE: it is recommended to use next/image for images
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
(<img <img
key={url} key={url}
src={url} src={url}
alt={name ?? "An image attachment"} alt={name ?? 'An image attachment'}
className="rounded-md size-full object-cover" className="rounded-md size-full object-cover"
/>) />
) : ( ) : (
<div className=""></div> <div className=""></div>
) )
@ -38,6 +38,6 @@ export const PreviewAttachment = ({
)} )}
</div> </div>
<div className="text-xs text-zinc-500 max-w-16 truncate">{name}</div> <div className="text-xs text-zinc-500 max-w-16 truncate">{name}</div>
</div>) </div>
); );
}; };

View file

@ -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<Array<Chat>>(user ? '/api/history' : null, fetcher, {
fallbackData: [],
});
useEffect(() => {
mutate();
}, [pathname, mutate]);
const [deleteId, setDeleteId] = useState<string | null>(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 (
<SidebarGroup>
<SidebarGroupLabel>History</SidebarGroupLabel>
<SidebarGroupContent>
<div className="text-zinc-500 h-dvh w-full flex flex-row justify-center items-center text-sm gap-2">
<InfoIcon />
<div>Login to save and revisit previous chats!</div>
</div>
</SidebarGroupContent>
</SidebarGroup>
);
}
if (isLoading) {
return (
<SidebarGroup>
<SidebarGroupLabel>History</SidebarGroupLabel>
<SidebarGroupContent>
<div className="flex flex-col">
{[44, 32, 28, 64, 52].map((item) => (
<div
key={item}
className="rounded-md h-8 flex gap-2 px-2 items-center"
>
<div
className="h-4 rounded-md flex-1 max-w-[--skeleton-width] bg-sidebar-accent-foreground/10"
style={
{
'--skeleton-width': `${item}%`,
} as React.CSSProperties
}
/>
</div>
))}
</div>
</SidebarGroupContent>
</SidebarGroup>
);
}
if (history?.length === 0) {
return (
<SidebarGroup>
<SidebarGroupLabel>History</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton>
<InfoIcon />
<span>No chats found</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
return (
<>
<SidebarGroup>
<SidebarGroupLabel>History</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{history &&
history.map((chat) => (
<SidebarMenuItem key={chat.id}>
<SidebarMenuButton asChild isActive={chat.id === id}>
<Link
href={`/chat/${chat.id}`}
onClick={() => setOpenMobile(false)}
>
<span>{getTitleFromChat(chat)}</span>
</Link>
</SidebarMenuButton>
<DropdownMenu modal={true}>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
showOnHover={chat.id !== id}
>
<MoreHorizontalIcon />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem
className="text-destructive focus:bg-destructive/15 focus:text-destructive"
onSelect={() => {
setDeleteId(chat.id);
setShowDeleteDialog(true);
}}
>
<TrashIcon />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete your
chat and remove it from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>
Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -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<typeof SidebarTrigger>) {
return (
<BetterTooltip content="Toggle Sidebar" align="start">
<SidebarTrigger
className={cn(
'size-10 md:size-8 [&>svg]:!size-5 md:[&>svg]:!size-4',
className
)}
/>
</BetterTooltip>
);
}

View file

@ -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 (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton className="data-[state=open]:bg-sidebar-accent bg-background data-[state=open]:text-sidebar-accent-foreground h-10">
<Image
src={`https://avatar.vercel.sh/${user.email}`}
alt={user.email ?? 'User Avatar'}
width={24}
height={24}
className="rounded-full"
/>
<span>{user?.email}</span>
<ChevronUp className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
className="w-[--radix-popper-anchor-width]"
>
<DropdownMenuItem
onSelect={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
>
{`Toggle ${theme === 'light' ? 'dark' : 'light'} mode`}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<button
className="w-full "
onClick={() => {
signOut({
redirectTo: '/',
});
}}
>
Sign out
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}

View file

@ -0,0 +1,25 @@
import Form from 'next/form';
import { signOut } from '@/app/(auth)/auth';
export const SignOutForm = () => {
return (
<Form
className="w-full"
action={async () => {
'use server';
await signOut({
redirectTo: '/',
});
}}
>
<button
type="submit"
className="w-full text-left px-1 py-0.5 text-red-500"
>
Sign out
</button>
</Form>
);
};

View file

@ -1,8 +1,7 @@
"use client"; 'use client';
import { ThemeProvider as NextThemesProvider } from "next-themes"; import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { type ThemeProviderProps } from "next-themes/dist/types"; import { type ThemeProviderProps } from 'next-themes/dist/types';
import * as React from "react";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) { export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>; return <NextThemesProvider {...props}>{children}</NextThemesProvider>;

View file

@ -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 <div className="cursor-pointer" />;
}
return (
<div
className="cursor-pointer"
onClick={() => {
setTheme(theme === 'dark' ? 'light' : 'dark');
}}
>
{`Toggle ${theme === 'light' ? 'dark' : 'light'} mode`}
</div>
);
}

View file

@ -1,8 +1,8 @@
"use client"; 'use client';
import cx from "classnames"; import cx from 'classnames';
import { format, isWithinInterval } from "date-fns"; import { format, isWithinInterval } from 'date-fns';
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react';
interface WeatherAtLocation { interface WeatherAtLocation {
latitude: number; latitude: number;
@ -47,114 +47,114 @@ const SAMPLE = {
longitude: -122.41286, longitude: -122.41286,
generationtime_ms: 0.027894973754882812, generationtime_ms: 0.027894973754882812,
utc_offset_seconds: 0, utc_offset_seconds: 0,
timezone: "GMT", timezone: 'GMT',
timezone_abbreviation: "GMT", timezone_abbreviation: 'GMT',
elevation: 18, elevation: 18,
current_units: { time: "iso8601", interval: "seconds", temperature_2m: "°C" }, current_units: { time: 'iso8601', interval: 'seconds', temperature_2m: '°C' },
current: { time: "2024-10-07T19:30", interval: 900, temperature_2m: 29.3 }, current: { time: '2024-10-07T19:30', interval: 900, temperature_2m: 29.3 },
hourly_units: { time: "iso8601", temperature_2m: "°C" }, hourly_units: { time: 'iso8601', temperature_2m: '°C' },
hourly: { hourly: {
time: [ time: [
"2024-10-07T00:00", '2024-10-07T00:00',
"2024-10-07T01:00", '2024-10-07T01:00',
"2024-10-07T02:00", '2024-10-07T02:00',
"2024-10-07T03:00", '2024-10-07T03:00',
"2024-10-07T04:00", '2024-10-07T04:00',
"2024-10-07T05:00", '2024-10-07T05:00',
"2024-10-07T06:00", '2024-10-07T06:00',
"2024-10-07T07:00", '2024-10-07T07:00',
"2024-10-07T08:00", '2024-10-07T08:00',
"2024-10-07T09:00", '2024-10-07T09:00',
"2024-10-07T10:00", '2024-10-07T10:00',
"2024-10-07T11:00", '2024-10-07T11:00',
"2024-10-07T12:00", '2024-10-07T12:00',
"2024-10-07T13:00", '2024-10-07T13:00',
"2024-10-07T14:00", '2024-10-07T14:00',
"2024-10-07T15:00", '2024-10-07T15:00',
"2024-10-07T16:00", '2024-10-07T16:00',
"2024-10-07T17:00", '2024-10-07T17:00',
"2024-10-07T18:00", '2024-10-07T18:00',
"2024-10-07T19:00", '2024-10-07T19:00',
"2024-10-07T20:00", '2024-10-07T20:00',
"2024-10-07T21:00", '2024-10-07T21:00',
"2024-10-07T22:00", '2024-10-07T22:00',
"2024-10-07T23:00", '2024-10-07T23:00',
"2024-10-08T00:00", '2024-10-08T00:00',
"2024-10-08T01:00", '2024-10-08T01:00',
"2024-10-08T02:00", '2024-10-08T02:00',
"2024-10-08T03:00", '2024-10-08T03:00',
"2024-10-08T04:00", '2024-10-08T04:00',
"2024-10-08T05:00", '2024-10-08T05:00',
"2024-10-08T06:00", '2024-10-08T06:00',
"2024-10-08T07:00", '2024-10-08T07:00',
"2024-10-08T08:00", '2024-10-08T08:00',
"2024-10-08T09:00", '2024-10-08T09:00',
"2024-10-08T10:00", '2024-10-08T10:00',
"2024-10-08T11:00", '2024-10-08T11:00',
"2024-10-08T12:00", '2024-10-08T12:00',
"2024-10-08T13:00", '2024-10-08T13:00',
"2024-10-08T14:00", '2024-10-08T14:00',
"2024-10-08T15:00", '2024-10-08T15:00',
"2024-10-08T16:00", '2024-10-08T16:00',
"2024-10-08T17:00", '2024-10-08T17:00',
"2024-10-08T18:00", '2024-10-08T18:00',
"2024-10-08T19:00", '2024-10-08T19:00',
"2024-10-08T20:00", '2024-10-08T20:00',
"2024-10-08T21:00", '2024-10-08T21:00',
"2024-10-08T22:00", '2024-10-08T22:00',
"2024-10-08T23:00", '2024-10-08T23:00',
"2024-10-09T00:00", '2024-10-09T00:00',
"2024-10-09T01:00", '2024-10-09T01:00',
"2024-10-09T02:00", '2024-10-09T02:00',
"2024-10-09T03:00", '2024-10-09T03:00',
"2024-10-09T04:00", '2024-10-09T04:00',
"2024-10-09T05:00", '2024-10-09T05:00',
"2024-10-09T06:00", '2024-10-09T06:00',
"2024-10-09T07:00", '2024-10-09T07:00',
"2024-10-09T08:00", '2024-10-09T08:00',
"2024-10-09T09:00", '2024-10-09T09:00',
"2024-10-09T10:00", '2024-10-09T10:00',
"2024-10-09T11:00", '2024-10-09T11:00',
"2024-10-09T12:00", '2024-10-09T12:00',
"2024-10-09T13:00", '2024-10-09T13:00',
"2024-10-09T14:00", '2024-10-09T14:00',
"2024-10-09T15:00", '2024-10-09T15:00',
"2024-10-09T16:00", '2024-10-09T16:00',
"2024-10-09T17:00", '2024-10-09T17:00',
"2024-10-09T18:00", '2024-10-09T18:00',
"2024-10-09T19:00", '2024-10-09T19:00',
"2024-10-09T20:00", '2024-10-09T20:00',
"2024-10-09T21:00", '2024-10-09T21:00',
"2024-10-09T22:00", '2024-10-09T22:00',
"2024-10-09T23:00", '2024-10-09T23:00',
"2024-10-10T00:00", '2024-10-10T00:00',
"2024-10-10T01:00", '2024-10-10T01:00',
"2024-10-10T02:00", '2024-10-10T02:00',
"2024-10-10T03:00", '2024-10-10T03:00',
"2024-10-10T04:00", '2024-10-10T04:00',
"2024-10-10T05:00", '2024-10-10T05:00',
"2024-10-10T06:00", '2024-10-10T06:00',
"2024-10-10T07:00", '2024-10-10T07:00',
"2024-10-10T08:00", '2024-10-10T08:00',
"2024-10-10T09:00", '2024-10-10T09:00',
"2024-10-10T10:00", '2024-10-10T10:00',
"2024-10-10T11:00", '2024-10-10T11:00',
"2024-10-10T12:00", '2024-10-10T12:00',
"2024-10-10T13:00", '2024-10-10T13:00',
"2024-10-10T14:00", '2024-10-10T14:00',
"2024-10-10T15:00", '2024-10-10T15:00',
"2024-10-10T16:00", '2024-10-10T16:00',
"2024-10-10T17:00", '2024-10-10T17:00',
"2024-10-10T18:00", '2024-10-10T18:00',
"2024-10-10T19:00", '2024-10-10T19:00',
"2024-10-10T20:00", '2024-10-10T20:00',
"2024-10-10T21:00", '2024-10-10T21:00',
"2024-10-10T22:00", '2024-10-10T22:00',
"2024-10-10T23:00", '2024-10-10T23:00',
"2024-10-11T00:00", '2024-10-11T00:00',
"2024-10-11T01:00", '2024-10-11T01:00',
"2024-10-11T02:00", '2024-10-11T02:00',
"2024-10-11T03:00", '2024-10-11T03:00',
], ],
temperature_2m: [ 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, 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: { daily_units: {
time: "iso8601", time: 'iso8601',
sunrise: "iso8601", sunrise: 'iso8601',
sunset: "iso8601", sunset: 'iso8601',
}, },
daily: { daily: {
time: [ time: [
"2024-10-07", '2024-10-07',
"2024-10-08", '2024-10-08',
"2024-10-09", '2024-10-09',
"2024-10-10", '2024-10-10',
"2024-10-11", '2024-10-11',
], ],
sunrise: [ sunrise: [
"2024-10-07T07:15", '2024-10-07T07:15',
"2024-10-08T07:16", '2024-10-08T07:16',
"2024-10-09T07:17", '2024-10-09T07:17',
"2024-10-10T07:18", '2024-10-10T07:18',
"2024-10-11T07:19", '2024-10-11T07:19',
], ],
sunset: [ sunset: [
"2024-10-07T19:00", '2024-10-07T19:00',
"2024-10-08T18:58", '2024-10-08T18:58',
"2024-10-09T18:57", '2024-10-09T18:57',
"2024-10-10T18:55", '2024-10-10T18:55',
"2024-10-11T18:54", '2024-10-11T18:54',
], ],
}, },
}; };
@ -207,10 +207,10 @@ export function Weather({
weatherAtLocation?: WeatherAtLocation; weatherAtLocation?: WeatherAtLocation;
}) { }) {
const currentHigh = Math.max( const currentHigh = Math.max(
...weatherAtLocation.hourly.temperature_2m.slice(0, 24), ...weatherAtLocation.hourly.temperature_2m.slice(0, 24)
); );
const currentLow = Math.min( 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), { const isDay = isWithinInterval(new Date(weatherAtLocation.current.time), {
@ -226,51 +226,51 @@ export function Weather({
}; };
handleResize(); handleResize();
window.addEventListener("resize", handleResize); window.addEventListener('resize', handleResize);
return () => window.removeEventListener("resize", handleResize); return () => window.removeEventListener('resize', handleResize);
}, []); }, []);
const hoursToShow = isMobile ? 5 : 6; const hoursToShow = isMobile ? 5 : 6;
// Find the index of the current time or the next closest time // Find the index of the current time or the next closest time
const currentTimeIndex = weatherAtLocation.hourly.time.findIndex( 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 // Slice the arrays to get the desired number of items
const displayTimes = weatherAtLocation.hourly.time.slice( const displayTimes = weatherAtLocation.hourly.time.slice(
currentTimeIndex, currentTimeIndex,
currentTimeIndex + hoursToShow, currentTimeIndex + hoursToShow
); );
const displayTemperatures = weatherAtLocation.hourly.temperature_2m.slice( const displayTemperatures = weatherAtLocation.hourly.temperature_2m.slice(
currentTimeIndex, currentTimeIndex,
currentTimeIndex + hoursToShow, currentTimeIndex + hoursToShow
); );
return ( return (
<div <div
className={cx( className={cx(
"flex flex-col gap-4 rounded-2xl p-4 skeleton-bg", 'flex flex-col gap-4 rounded-2xl p-4 skeleton-bg max-w-[500px]',
{ {
"bg-blue-400": isDay, 'bg-blue-400': isDay,
}, },
{ {
"bg-indigo-900": !isDay, 'bg-indigo-900': !isDay,
}, }
)} )}
> >
<div className="flex flex-row justify-between items-center"> <div className="flex flex-row justify-between items-center">
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-2 items-center">
<div <div
className={cx( className={cx(
"size-10 rounded-full skeleton-div", 'size-10 rounded-full skeleton-div',
{ {
"bg-yellow-300": isDay, 'bg-yellow-300': isDay,
}, },
{ {
"bg-indigo-100": !isDay, 'bg-indigo-100': !isDay,
}, }
)} )}
/> />
<div className="text-4xl font-medium text-blue-50"> <div className="text-4xl font-medium text-blue-50">
@ -286,17 +286,17 @@ export function Weather({
{displayTimes.map((time, index) => ( {displayTimes.map((time, index) => (
<div key={time} className="flex flex-col items-center gap-1"> <div key={time} className="flex flex-col items-center gap-1">
<div className="text-blue-100 text-xs"> <div className="text-blue-100 text-xs">
{format(new Date(time), "ha")} {format(new Date(time), 'ha')}
</div> </div>
<div <div
className={cx( className={cx(
"size-6 rounded-full skeleton-div", 'size-6 rounded-full skeleton-div',
{ {
"bg-yellow-300": isDay, 'bg-yellow-300': isDay,
}, },
{ {
"bg-indigo-200": !isDay, 'bg-indigo-200': !isDay,
}, }
)} )}
/> />
<div className="text-blue-50 text-sm"> <div className="text-blue-50 text-sm">

View file

@ -1,56 +1,56 @@
import * as React from "react" import * as React from 'react';
import { Slot } from "@radix-ui/react-slot" import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const buttonVariants = cva( 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: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90", 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 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: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80", 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: 'hover:bg-accent hover:text-accent-foreground',
link: "text-primary underline-offset-4 hover:underline", link: 'text-primary underline-offset-4 hover:underline',
}, },
size: { size: {
default: "h-10 px-4 py-2", default: 'h-10 px-4 py-2',
sm: "h-9 rounded-md px-3", sm: 'h-9 rounded-md px-3',
lg: "h-11 rounded-md px-8", lg: 'h-11 rounded-md px-8',
icon: "h-10 w-10", icon: 'h-10 w-10',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
size: "default", size: 'default',
}, },
} }
) );
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean asChild?: boolean;
} }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => { ({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : 'button';
return ( return (
<Comp <Comp
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
ref={ref} ref={ref}
{...props} {...props}
/> />
) );
} }
) );
Button.displayName = "Button" Button.displayName = 'Button';
export { Button, buttonVariants } export { Button, buttonVariants };

79
components/ui/card.tsx Normal file
View file

@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View file

@ -1,34 +1,34 @@
"use client" 'use client';
import * as React from "react" import * as React from 'react';
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from "lucide-react" 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< const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, children, ...props }, ref) => ( >(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
ref={ref} ref={ref}
className={cn( className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent", 'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
inset && "pl-8", inset && 'pl-8',
className className
)} )}
{...props} {...props}
@ -36,9 +36,9 @@ const DropdownMenuSubTrigger = React.forwardRef<
{children} {children}
<ChevronRight className="ml-auto h-4 w-4" /> <ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
)) ));
DropdownMenuSubTrigger.displayName = DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef< const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@ -47,14 +47,14 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
ref={ref} ref={ref}
className={cn( className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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-md border bg-popover p-1 text-popover-foreground shadow-lg 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 className
)} )}
{...props} {...props}
/> />
)) ));
DropdownMenuSubContent.displayName = DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef< const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>, React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@ -65,32 +65,32 @@ const DropdownMenuContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( 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 className
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
)) ));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef< const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>, React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 'relative flex gap-2 cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && "pl-8", inset && 'pl-8',
className className
)} )}
{...props} {...props}
/> />
)) ));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef< const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
@ -99,7 +99,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className className
)} )}
checked={checked} checked={checked}
@ -112,9 +112,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
)) ));
DropdownMenuCheckboxItem.displayName = DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef< const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@ -123,7 +123,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className className
)} )}
{...props} {...props}
@ -135,26 +135,26 @@ const DropdownMenuRadioItem = React.forwardRef<
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
)) ));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef< const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>, React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
ref={ref} ref={ref}
className={cn( className={cn(
"px-2 py-1.5 text-sm font-semibold", 'px-2 py-1.5 text-sm font-semibold',
inset && "pl-8", inset && 'pl-8',
className className
)} )}
{...props} {...props}
/> />
)) ));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef< const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>, React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
@ -162,11 +162,11 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
ref={ref} ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)} className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props} {...props}
/> />
)) ));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ const DropdownMenuShortcut = ({
className, className,
@ -174,12 +174,12 @@ const DropdownMenuShortcut = ({
}: React.HTMLAttributes<HTMLSpanElement>) => { }: React.HTMLAttributes<HTMLSpanElement>) => {
return ( return (
<span <span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)} className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props} {...props}
/> />
) );
} };
DropdownMenuShortcut.displayName = "DropdownMenuShortcut" DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export { export {
DropdownMenu, DropdownMenu,
@ -197,4 +197,4 @@ export {
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
} };

160
components/ui/select.tsx Normal file
View file

@ -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<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover 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",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View file

@ -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<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

773
components/ui/sidebar.tsx Normal file
View file

@ -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<SidebarContext | null>(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<SidebarContext>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
'group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar',
className
)}
data-state={state}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
);
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 (
<div
className={cn(
'flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground',
className
)}
ref={ref}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetTitle className="sr-only">Sidebar</SheetTitle>
<SheetDescription className="sr-only">
Mobile sidebar
</SheetDescription>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
ref={ref}
className="group peer hidden md:block text-sidebar-foreground"
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
'duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]'
: 'group-data-[collapsible=icon]:w-[--sidebar-width-icon]'
)}
/>
<div
className={cn(
'duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]'
: 'group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l',
className
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
);
}
);
Sidebar.displayName = 'Sidebar';
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft className="size-5" />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
});
SidebarTrigger.displayName = 'SidebarTrigger';
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<'button'>
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex',
'[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className
)}
{...props}
/>
);
});
SidebarRail.displayName = 'SidebarRail';
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'main'>
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
'relative flex min-h-svh flex-1 flex-col bg-background',
'peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow',
className
)}
{...props}
/>
);
});
SidebarInset.displayName = 'SidebarInset';
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
'h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring',
className
)}
{...props}
/>
);
});
SidebarInput.displayName = 'SidebarInput';
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
);
});
SidebarHeader.displayName = 'SidebarHeader';
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
);
});
SidebarFooter.displayName = 'SidebarFooter';
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn('mx-2 w-auto bg-sidebar-border', className)}
{...props}
/>
);
});
SidebarSeparator.displayName = 'SidebarSeparator';
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
className
)}
{...props}
/>
);
});
SidebarContent.displayName = 'SidebarContent';
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
{...props}
/>
);
});
SidebarGroup.displayName = 'SidebarGroup';
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'div';
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
'duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
className
)}
{...props}
/>
);
});
SidebarGroupLabel.displayName = 'SidebarGroupLabel';
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<'button'> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
'absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 after:md:hidden',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
);
});
SidebarGroupAction.displayName = 'SidebarGroupAction';
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn('w-full text-sm', className)}
{...props}
/>
));
SidebarGroupContent.displayName = 'SidebarGroupContent';
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
{...props}
/>
));
SidebarMenu.displayName = 'SidebarMenu';
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<'li'>
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn('group/menu-item relative', className)}
{...props}
/>
));
SidebarMenuItem.displayName = 'SidebarMenuItem';
const sidebarMenuButtonVariants = cva(
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
{
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:!p-0',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<'button'> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = 'default',
size = 'default',
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : 'button';
const { isMobile, state } = useSidebar();
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === 'string') {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== 'collapsed' || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
);
SidebarMenuButton.displayName = 'SidebarMenuButton';
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<'button'> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 after:md:hidden',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
showOnHover &&
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0',
className
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = 'SidebarMenuAction';
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
'absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none',
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
));
SidebarMenuBadge.displayName = 'SidebarMenuBadge';
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & {
showIcon?: boolean;
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn('rounded-md h-8 flex gap-2 px-2 items-center', className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md bg-sidebar-accent-foreground/10"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 flex-1 max-w-[--skeleton-width] bg-sidebar-accent-foreground/10"
data-sidebar="menu-skeleton-text"
style={
{
'--skeleton-width': width,
} as React.CSSProperties
}
/>
</div>
);
});
SidebarMenuSkeleton.displayName = 'SidebarMenuSkeleton';
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
));
SidebarMenuSub.displayName = 'SidebarMenuSub';
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<'li'>
>(({ ...props }, ref) => <li ref={ref} {...props} />);
SidebarMenuSubItem.displayName = 'SidebarMenuSubItem';
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<'a'> & {
asChild?: boolean;
size?: 'sm' | 'md';
isActive?: boolean;
}
>(({ asChild = false, size = 'md', isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : 'a';
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
size === 'sm' && 'text-xs',
size === 'md' && 'text-sm',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = 'SidebarMenuSubButton';
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View file

@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

49
components/ui/tooltip.tsx Normal file
View file

@ -0,0 +1,49 @@
'use client';
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
export const BetterTooltip = ({
content,
children,
align = 'center',
...props
}: React.ComponentPropsWithoutRef<typeof Tooltip> & {
content: JSX.Element | string;
align?: 'center' | 'end' | 'start';
}) => {
return (
<TooltipProvider delayDuration={0}>
<Tooltip {...props}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent align={align}>{content}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};

19
hooks/use-mobile.tsx Normal file
View file

@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

17
lib/model.ts Normal file
View file

@ -0,0 +1,17 @@
// Define your models here.
export const models = [
{
label: 'GPT 4o',
name: 'gpt-4o',
description: 'For complex, multi-step tasks',
},
{
label: 'GPT 4o mini',
name: 'gpt-4o-mini',
description: 'Small model for fast, lightweight tasks',
},
] as const;
export const DEFAULT_MODEL_NAME: Model['name'] = 'gpt-4o';
export type Model = (typeof models)[number];

View file

@ -3,7 +3,11 @@ import type { NextConfig } from 'next';
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
images: { images: {
remotePatterns: [], remotePatterns: [
{
hostname: 'avatar.vercel.sh',
},
],
}, },
}; };

View file

@ -15,6 +15,8 @@
"@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.2",
"@radix-ui/react-visually-hidden": "^1.1.0", "@radix-ui/react-visually-hidden": "^1.1.0",

98
pnpm-lock.yaml generated
View file

@ -26,6 +26,12 @@ importers:
'@radix-ui/react-label': '@radix-ui/react-label':
specifier: ^2.1.0 specifier: ^2.1.0
version: 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021) version: 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
'@radix-ui/react-select':
specifier: ^2.1.2
version: 2.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
'@radix-ui/react-separator':
specifier: ^1.1.0
version: 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
'@radix-ui/react-slot': '@radix-ui/react-slot':
specifier: ^1.1.0 specifier: ^1.1.0
version: 1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021) version: 1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
@ -299,8 +305,8 @@ packages:
'@drizzle-team/brocli@0.10.1': '@drizzle-team/brocli@0.10.1':
resolution: {integrity: sha512-AHy0vjc+n/4w/8Mif+w86qpppHuF3AyXbcWW+R/W7GNA3F5/p2nuhlkCJaTXSLZheB4l1rtHzOfr9A7NwoR/Zg==} resolution: {integrity: sha512-AHy0vjc+n/4w/8Mif+w86qpppHuF3AyXbcWW+R/W7GNA3F5/p2nuhlkCJaTXSLZheB4l1rtHzOfr9A7NwoR/Zg==}
'@emnapi/runtime@1.2.0': '@emnapi/runtime@1.3.1':
resolution: {integrity: sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==} resolution: {integrity: sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==}
'@esbuild-kit/core-utils@3.3.2': '@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
@ -985,6 +991,9 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} engines: {node: '>=14'}
'@radix-ui/number@1.1.0':
resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==}
'@radix-ui/primitive@1.1.0': '@radix-ui/primitive@1.1.0':
resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==} resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==}
@ -1277,6 +1286,32 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-select@2.1.2':
resolution: {integrity: sha512-rZJtWmorC7dFRi0owDmoijm6nSJH1tVw64QGiNIZ9PNLyBDtG+iAq+XGsya052At4BfarzY/Dhv9wrrUr6IMZA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-separator@1.1.0':
resolution: {integrity: sha512-3uBAs+egzvJBDZAzvb/n4NxxOYpnspmWxO2u5NbZ8Y6FM/NdrGSF9bop3Cf6F6C71z1rTSn8KV0Fo2ZVd79lGA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.1.0': '@radix-ui/react-slot@1.1.0':
resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==} resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==}
peerDependencies: peerDependencies:
@ -1335,6 +1370,15 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-use-previous@1.1.0':
resolution: {integrity: sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-rect@1.1.0': '@radix-ui/react-use-rect@1.1.0':
resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==} resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==}
peerDependencies: peerDependencies:
@ -3794,7 +3838,7 @@ snapshots:
'@drizzle-team/brocli@0.10.1': {} '@drizzle-team/brocli@0.10.1': {}
'@emnapi/runtime@1.2.0': '@emnapi/runtime@1.3.1':
dependencies: dependencies:
tslib: 2.6.3 tslib: 2.6.3
optional: true optional: true
@ -4136,7 +4180,7 @@ snapshots:
'@img/sharp-wasm32@0.33.5': '@img/sharp-wasm32@0.33.5':
dependencies: dependencies:
'@emnapi/runtime': 1.2.0 '@emnapi/runtime': 1.3.1
optional: true optional: true
'@img/sharp-win32-ia32@0.33.5': '@img/sharp-win32-ia32@0.33.5':
@ -4226,6 +4270,8 @@ snapshots:
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
'@radix-ui/number@1.1.0': {}
'@radix-ui/primitive@1.1.0': {} '@radix-ui/primitive@1.1.0': {}
'@radix-ui/react-alert-dialog@1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)': '@radix-ui/react-alert-dialog@1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)':
@ -4503,6 +4549,44 @@ snapshots:
'@types/react': 18.3.3 '@types/react': 18.3.3
'@types/react-dom': 18.3.0 '@types/react-dom': 18.3.0
'@radix-ui/react-select@2.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)':
dependencies:
'@radix-ui/number': 1.1.0
'@radix-ui/primitive': 1.1.0
'@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
'@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
'@radix-ui/react-context': 1.1.1(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
'@radix-ui/react-direction': 1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
'@radix-ui/react-dismissable-layer': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
'@radix-ui/react-focus-guards': 1.1.1(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
'@radix-ui/react-focus-scope': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
'@radix-ui/react-id': 1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
'@radix-ui/react-popper': 1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
'@radix-ui/react-portal': 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
'@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
'@radix-ui/react-slot': 1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
'@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
'@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
'@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
'@radix-ui/react-visually-hidden': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
aria-hidden: 1.2.4
react: 19.0.0-rc-45804af1-20241021
react-dom: 19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021)
react-remove-scroll: 2.6.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
optionalDependencies:
'@types/react': 18.3.3
'@types/react-dom': 18.3.0
'@radix-ui/react-separator@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)':
dependencies:
'@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
react: 19.0.0-rc-45804af1-20241021
react-dom: 19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021)
optionalDependencies:
'@types/react': 18.3.3
'@types/react-dom': 18.3.0
'@radix-ui/react-slot@1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)': '@radix-ui/react-slot@1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)':
dependencies: dependencies:
'@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021) '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
@ -4556,6 +4640,12 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 18.3.3 '@types/react': 18.3.3
'@radix-ui/react-use-previous@1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)':
dependencies:
react: 19.0.0-rc-45804af1-20241021
optionalDependencies:
'@types/react': 18.3.3
'@radix-ui/react-use-rect@1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)': '@radix-ui/react-use-rect@1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)':
dependencies: dependencies:
'@radix-ui/rect': 1.1.0 '@radix-ui/rect': 1.1.0

9
prettier.config.cjs Normal file
View file

@ -0,0 +1,9 @@
/** @type {import('prettier').Config} */
module.exports = {
endOfLine: 'lf',
semi: true,
useTabs: false,
singleQuote: true,
tabWidth: 2,
trailingComma: 'es5',
};

View file

@ -1,68 +1,78 @@
import type { Config } from "tailwindcss"; import type { Config } from 'tailwindcss';
const config: Config = { const config: Config = {
darkMode: ["class"], darkMode: ['class'],
content: [ content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}", './pages/**/*.{js,ts,jsx,tsx,mdx}',
"./components/**/*.{js,ts,jsx,tsx,mdx}", './components/**/*.{js,ts,jsx,tsx,mdx}',
"./app/**/*.{js,ts,jsx,tsx,mdx}", './app/**/*.{js,ts,jsx,tsx,mdx}',
], ],
theme: { theme: {
fontFamily: { fontFamily: {
sans: ["geist"], sans: ['geist'],
mono: ["geist-mono"], mono: ['geist-mono'],
}, },
extend: { extend: {
borderRadius: { borderRadius: {
lg: "var(--radius)", lg: 'var(--radius)',
md: "calc(var(--radius) - 2px)", md: 'calc(var(--radius) - 2px)',
sm: "calc(var(--radius) - 4px)", sm: 'calc(var(--radius) - 4px)',
}, },
colors: { colors: {
background: "hsl(var(--background))", background: 'hsl(var(--background))',
foreground: "hsl(var(--foreground))", foreground: 'hsl(var(--foreground))',
card: { card: {
DEFAULT: "hsl(var(--card))", DEFAULT: 'hsl(var(--card))',
foreground: "hsl(var(--card-foreground))", foreground: 'hsl(var(--card-foreground))',
}, },
popover: { popover: {
DEFAULT: "hsl(var(--popover))", DEFAULT: 'hsl(var(--popover))',
foreground: "hsl(var(--popover-foreground))", foreground: 'hsl(var(--popover-foreground))',
}, },
primary: { primary: {
DEFAULT: "hsl(var(--primary))", DEFAULT: 'hsl(var(--primary))',
foreground: "hsl(var(--primary-foreground))", foreground: 'hsl(var(--primary-foreground))',
}, },
secondary: { secondary: {
DEFAULT: "hsl(var(--secondary))", DEFAULT: 'hsl(var(--secondary))',
foreground: "hsl(var(--secondary-foreground))", foreground: 'hsl(var(--secondary-foreground))',
}, },
muted: { muted: {
DEFAULT: "hsl(var(--muted))", DEFAULT: 'hsl(var(--muted))',
foreground: "hsl(var(--muted-foreground))", foreground: 'hsl(var(--muted-foreground))',
}, },
accent: { accent: {
DEFAULT: "hsl(var(--accent))", DEFAULT: 'hsl(var(--accent))',
foreground: "hsl(var(--accent-foreground))", foreground: 'hsl(var(--accent-foreground))',
}, },
destructive: { destructive: {
DEFAULT: "hsl(var(--destructive))", DEFAULT: 'hsl(var(--destructive))',
foreground: "hsl(var(--destructive-foreground))", foreground: 'hsl(var(--destructive-foreground))',
}, },
border: "hsl(var(--border))", border: 'hsl(var(--border))',
input: "hsl(var(--input))", input: 'hsl(var(--input))',
ring: "hsl(var(--ring))", ring: 'hsl(var(--ring))',
chart: { chart: {
"1": "hsl(var(--chart-1))", '1': 'hsl(var(--chart-1))',
"2": "hsl(var(--chart-2))", '2': 'hsl(var(--chart-2))',
"3": "hsl(var(--chart-3))", '3': 'hsl(var(--chart-3))',
"4": "hsl(var(--chart-4))", '4': 'hsl(var(--chart-4))',
"5": "hsl(var(--chart-5))", '5': 'hsl(var(--chart-5))',
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))',
}, },
}, },
}, },
}, },
plugins: [require("tailwindcss-animate")], plugins: [require('tailwindcss-animate')],
safelist: ["w-32", "w-44", "w-52"], safelist: ['w-32', 'w-44', 'w-52'],
}; };
export default config; export default config;

View file

@ -22,6 +22,12 @@
"@/*": ["./*"] "@/*": ["./*"]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"next.config.js"
],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }