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 { experimental_wrapLanguageModel as wrapLanguageModel } from "ai";
import { customMiddleware } from "./custom-middleware";
import { openai } from '@ai-sdk/openai';
import { experimental_wrapLanguageModel as wrapLanguageModel } from 'ai';
export const customModel = wrapLanguageModel({
model: openai("gpt-4o"),
import { type Model } from '@/lib/model';
import { customMiddleware } from './custom-middleware';
export const customModel = (modelName: Model['name']) => {
return wrapLanguageModel({
model: openai(modelName),
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 { z } from "zod";
import { convertToCoreMessages, Message, streamText } from 'ai';
import { z } from 'zod';
import { customModel } from "@/ai";
import { auth } from "@/app/(auth)/auth";
import { deleteChatById, getChatById, saveChat } from "@/db/queries";
import { customModel } from '@/ai';
import { auth } from '@/app/(auth)/auth';
import { deleteChatById, getChatById, saveChat } from '@/db/queries';
import { Model, models } from '@/lib/model';
export async function POST(request: Request) {
const { id, messages }: { id: string; messages: Array<Message> } =
const {
id,
messages,
model,
}: { id: string; messages: Array<Message>; model: Model['name'] } =
await request.json();
const session = await auth();
if (!session) {
return new Response("Unauthorized", { status: 401 });
return new Response('Unauthorized', { status: 401 });
}
if (!models.find((m) => m.name === model)) {
return new Response('Model not found', { status: 404 });
}
const coreMessages = convertToCoreMessages(messages);
const result = await streamText({
model: customModel,
model: customModel(model),
system:
"you are a friendly assistant! keep your responses concise and helpful.",
'you are a friendly assistant! keep your responses concise and helpful.',
messages: coreMessages,
maxSteps: 5,
tools: {
getWeather: {
description: "Get the current weather at a location",
description: 'Get the current weather at a location',
parameters: z.object({
latitude: z.number(),
longitude: z.number(),
}),
execute: async ({ latitude, longitude }) => {
const response = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&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();
@ -49,13 +58,13 @@ export async function POST(request: Request) {
userId: session.user.id,
});
} catch (error) {
console.error("Failed to save chat");
console.error('Failed to save chat');
}
}
},
experimental_telemetry: {
isEnabled: true,
functionId: "stream-text",
functionId: 'stream-text',
},
});
@ -64,30 +73,30 @@ export async function POST(request: Request) {
export async function DELETE(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
const id = searchParams.get('id');
if (!id) {
return new Response("Not Found", { status: 404 });
return new Response('Not Found', { status: 404 });
}
const session = await auth();
if (!session || !session.user) {
return new Response("Unauthorized", { status: 401 });
return new Response('Unauthorized', { status: 401 });
}
try {
const chat = await getChatById({ id });
if (chat.userId !== session.user.id) {
return new Response("Unauthorized", { status: 401 });
return new Response('Unauthorized', { status: 401 });
}
await deleteChatById({ id });
return new Response("Chat deleted", { status: 200 });
return new Response('Chat deleted', { status: 200 });
} catch (error) {
return new Response("An error occurred while processing your request", {
return new Response('An error occurred while processing your request', {
status: 500,
});
}

View file

@ -1,11 +1,13 @@
import { CoreMessage } from "ai";
import { notFound } from "next/navigation";
import { CoreMessage } from 'ai';
import { cookies } from 'next/headers';
import { notFound } from 'next/navigation';
import { auth } from "@/app/(auth)/auth";
import { Chat as PreviewChat } from "@/components/custom/chat";
import { getChatById } from "@/db/queries";
import { Chat } from "@/db/schema";
import { convertToUIMessages, generateUUID } from "@/lib/utils";
import { auth } from '@/app/(auth)/auth';
import { Chat as PreviewChat } from '@/components/custom/chat';
import { getChatById } from '@/db/queries';
import { Chat } from '@/db/schema';
import { DEFAULT_MODEL_NAME, models } from '@/lib/model';
import { convertToUIMessages } from '@/lib/utils';
export default async function Page(props: { params: Promise<any> }) {
const params = await props.params;
@ -32,5 +34,16 @@ export default async function Page(props: { params: Promise<any> }) {
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 { generateUUID } from "@/lib/utils";
import { cookies } from 'next/headers';
import { Chat } from '@/components/custom/chat';
import { DEFAULT_MODEL_NAME, models } from '@/lib/model';
import { generateUUID } from '@/lib/utils';
export default async function Page() {
const id = generateUUID();
return <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-5: 27 87% 67%;
--radius: 0.5rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 10% 3.9%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 5.9% 94%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 240 10% 3.9%;
@ -75,6 +83,14 @@
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
@ -88,17 +104,17 @@
}
@font-face {
font-family: "geist";
font-family: 'geist';
font-style: normal;
font-weight: 100 900;
src: url(/fonts/geist.woff2) format("woff2");
src: url(/fonts/geist.woff2) format('woff2');
}
@font-face {
font-family: "geist-mono";
font-family: 'geist-mono';
font-style: normal;
font-weight: 100 900;
src: url(/fonts/geist-mono.woff2) format("woff2");
src: url(/fonts/geist-mono.woff2) format('woff2');
}
}
@ -107,7 +123,7 @@
pointer-events: none !important;
}
*[class^="text-"] {
*[class^='text-'] {
color: transparent;
@apply rounded-md bg-foreground/20 select-none animate-pulse;
}

View file

@ -1,7 +1,6 @@
import { Metadata } from 'next';
import { Toaster } from 'sonner';
import { Navbar } from '@/components/custom/navbar';
import { ThemeProvider } from '@/components/custom/theme-provider';
import './globals.css';
@ -65,7 +64,6 @@ export default async function RootLayout({
disableTransitionOnChange
>
<Toaster position="top-center" />
<Navbar />
{children}
</ThemeProvider>
</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 { useChat } from "ai/react";
import { useState } from "react";
import { Attachment, Message } from 'ai';
import { useChat } from 'ai/react';
import { useState } from 'react';
import { Message as PreviewMessage } from "@/components/custom/message";
import { useScrollToBottom } from "@/components/custom/use-scroll-to-bottom";
import { ChatHeader } from '@/components/custom/chat-header';
import { Message as PreviewMessage } from '@/components/custom/message';
import { useScrollToBottom } from '@/components/custom/use-scroll-to-bottom';
import { Model } from '@/lib/model';
import { MultimodalInput } from "./multimodal-input";
import { Overview } from "./overview";
import { MultimodalInput } from './multimodal-input';
import { Overview } from './overview';
export function Chat({
id,
initialMessages,
selectedModelName,
}: {
id: string;
initialMessages: Array<Message>;
selectedModelName: Model['name'];
}) {
const { messages, handleSubmit, input, setInput, append, isLoading, stop } =
useChat({
body: { id },
body: { id, model: selectedModelName },
initialMessages,
onFinish: () => {
window.history.replaceState({}, "", `/chat/${id}`);
window.history.replaceState({}, '', `/chat/${id}`);
},
});
@ -32,11 +36,11 @@ export function Chat({
const [attachments, setAttachments] = useState<Array<Attachment>>([]);
return (
<div className="flex flex-row justify-center pb-4 md:pb-8 h-dvh bg-background">
<div className="flex flex-col justify-between items-center gap-4">
<div className="flex flex-col min-w-0 h-dvh bg-background">
<ChatHeader selectedModelName={selectedModelName} />
<div
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 />}
@ -55,8 +59,7 @@ export function Chat({
className="shrink-0 min-w-[24px] min-h-[24px]"
/>
</div>
<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">
<form className="flex mx-auto px-4 bg-background pb-4 md:pb-6 gap-2 w-full md:max-w-3xl">
<MultimodalInput
input={input}
setInput={setInput}
@ -70,6 +73,5 @@ export function Chat({
/>
</form>
</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 { motion } from "framer-motion";
import { ReactNode } from "react";
import { Attachment, ToolInvocation } from 'ai';
import { motion } from 'framer-motion';
import { Sparkles } from 'lucide-react';
import { ReactNode } from 'react';
import { BotIcon, UserIcon } from "./icons";
import { Markdown } from "./markdown";
import { PreviewAttachment } from "./preview-attachment";
import { Weather } from "./weather";
import { Markdown } from './markdown';
import { PreviewAttachment } from './preview-attachment';
import { Weather } from './weather';
export const Message = ({
role,
@ -22,32 +22,35 @@ export const Message = ({
}) => {
return (
<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 }}
animate={{ y: 0, opacity: 1 }}
data-role={role}
>
<div className="size-[24px] flex flex-col justify-center items-center shrink-0 text-zinc-400">
{role === "assistant" ? <BotIcon /> : <UserIcon />}
<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' && (
<div className="size-8 flex items-center rounded-full justify-center ring-1 shrink-0 ring-border">
<Sparkles className="size-4" />
</div>
)}
<div className="flex flex-col gap-2 w-full">
{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>
</div>
)}
{toolInvocations && (
{toolInvocations && toolInvocations.length > 0 ? (
<div className="flex flex-col gap-4">
{toolInvocations.map((toolInvocation) => {
const { toolName, toolCallId, state } = toolInvocation;
if (state === "result") {
if (state === 'result') {
const { result } = toolInvocation;
return (
<div key={toolCallId}>
{toolName === "getWeather" ? (
{toolName === 'getWeather' ? (
<Weather weatherAtLocation={result} />
) : null}
</div>
@ -55,22 +58,26 @@ export const Message = ({
} else {
return (
<div key={toolCallId} className="skeleton">
{toolName === "getWeather" ? <Weather /> : null}
{toolName === 'getWeather' ? <Weather /> : null}
</div>
);
}
})}
</div>
)}
) : null}
{attachments && (
<div className="flex flex-row gap-2">
{attachments.map((attachment) => (
<PreviewAttachment key={attachment.url} attachment={attachment} />
<PreviewAttachment
key={attachment.url}
attachment={attachment}
/>
))}
</div>
)}
</div>
</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 { motion } from "framer-motion";
import { Attachment, ChatRequestOptions, CreateMessage, Message } from 'ai';
import { motion } from 'framer-motion';
import React, {
useRef,
useEffect,
@ -10,24 +10,24 @@ import React, {
Dispatch,
SetStateAction,
ChangeEvent,
} from "react";
import { toast } from "sonner";
} from 'react';
import { toast } from 'sonner';
import { ArrowUpIcon, PaperclipIcon, StopIcon } from "./icons";
import { PreviewAttachment } from "./preview-attachment";
import useWindowSize from "./use-window-size";
import { Button } from "../ui/button";
import { Textarea } from "../ui/textarea";
import { ArrowUpIcon, PaperclipIcon, StopIcon } from './icons';
import { PreviewAttachment } from './preview-attachment';
import useWindowSize from './use-window-size';
import { Button } from '../ui/button';
import { Textarea } from '../ui/textarea';
const suggestedActions = [
{
title: "What is the weather",
label: "in San Francisco?",
action: "What is the weather in San Francisco?",
title: 'What is the weather',
label: 'in San Francisco?',
action: 'What is the weather in San Francisco?',
},
{
title: "Answer like I'm 5,",
label: "why is the sky blue?",
label: 'why is the sky blue?',
action: "Answer like I'm 5, why is the sky blue?",
},
];
@ -52,13 +52,13 @@ export function MultimodalInput({
messages: Array<Message>;
append: (
message: Message | CreateMessage,
chatRequestOptions?: ChatRequestOptions,
chatRequestOptions?: ChatRequestOptions
) => Promise<string | null | undefined>;
handleSubmit: (
event?: {
preventDefault?: () => void;
},
chatRequestOptions?: ChatRequestOptions,
chatRequestOptions?: ChatRequestOptions
) => void;
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -72,7 +72,7 @@ export function MultimodalInput({
const adjustHeight = () => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`;
}
};
@ -99,11 +99,11 @@ export function MultimodalInput({
const uploadFile = async (file: File) => {
const formData = new FormData();
formData.append("file", file);
formData.append('file', file);
try {
const response = await fetch(`/api/files/upload`, {
method: "POST",
method: 'POST',
body: formData,
});
@ -121,7 +121,7 @@ export function MultimodalInput({
toast.error(error);
}
} catch (error) {
toast.error("Failed to upload file, please try again!");
toast.error('Failed to upload file, please try again!');
}
};
@ -135,7 +135,7 @@ export function MultimodalInput({
const uploadPromises = files.map((file) => uploadFile(file));
const uploadedAttachments = await Promise.all(uploadPromises);
const successfullyUploadedAttachments = uploadedAttachments.filter(
(attachment) => attachment !== undefined,
(attachment) => attachment !== undefined
);
setAttachments((currentAttachments) => [
@ -143,12 +143,12 @@ export function MultimodalInput({
...successfullyUploadedAttachments,
]);
} catch (error) {
console.error("Error uploading files!", error);
console.error('Error uploading files!', error);
} finally {
setUploadQueue([]);
}
},
[setAttachments],
[setAttachments]
);
return (
@ -156,7 +156,7 @@ export function MultimodalInput({
{messages.length === 0 &&
attachments.length === 0 &&
uploadQueue.length === 0 && (
<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) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
@ -164,22 +164,23 @@ export function MultimodalInput({
exit={{ opacity: 0, y: 20 }}
transition={{ delay: 0.05 * index }}
key={index}
className={index > 1 ? "hidden sm:block" : "block"}
className={index > 1 ? 'hidden sm:block' : 'block'}
>
<button
<Button
variant="ghost"
onClick={async () => {
append({
role: "user",
role: 'user',
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="text-zinc-500 dark:text-zinc-400">
<span className="text-muted-foreground">
{suggestedAction.label}
</span>
</button>
</Button>
</motion.div>
))}
</div>
@ -204,9 +205,9 @@ export function MultimodalInput({
<PreviewAttachment
key={filename}
attachment={{
url: "",
url: '',
name: filename,
contentType: "",
contentType: '',
}}
isUploading={true}
/>
@ -219,14 +220,14 @@ export function MultimodalInput({
placeholder="Send a message..."
value={input}
onChange={handleInput}
className="min-h-[24px] overflow-hidden resize-none rounded-lg text-base bg-muted"
className="min-h-[24px] overflow-hidden resize-none rounded-xl p-4 focus-visible:ring-0 focus-visible:ring-offset-0 text-base bg-muted border-none"
rows={3}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
if (isLoading) {
toast.error("Please wait for the model to finish its response!");
toast.error('Please wait for the model to finish its response!');
} else {
submitForm();
}

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 Link from "next/link";
import { motion } from 'framer-motion';
import Link from 'next/link';
import { LogoOpenAI, MessageIcon, VercelIcon } from "./icons";
import { MessageIcon, VercelIcon } from './icons';
export const Overview = () => {
return (
<motion.div
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 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
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">
<p className="flex flex-row justify-center gap-4 items-center text-zinc-900 dark:text-zinc-50">
<VercelIcon />
<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">
<VercelIcon size={32} />
<span>+</span>
<MessageIcon />
<MessageIcon size={32} />
</p>
<p>
This is an open source Chatbot template built with Next.js and the AI
SDK by Vercel. It uses the{" "}
<code className="rounded-md bg-muted px-1 py-0.5">streamText</code>{" "}
function in the server and the{" "}
SDK by Vercel. It uses the{' '}
<code className="rounded-md bg-muted px-1 py-0.5">streamText</code>{' '}
function in the server and the{' '}
<code className="rounded-md bg-muted px-1 py-0.5">useChat</code> hook
on the client to create a seamless chat experience.
</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
className="text-blue-500 dark:text-blue-400"
className="font-medium underline underline-offset-4"
href="https://sdk.vercel.ai/docs"
target="_blank"
>
Docs
docs
</Link>
.
</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 = ({
attachment,
@ -12,18 +12,18 @@ export const PreviewAttachment = ({
const { name, url, contentType } = attachment;
return (
(<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="flex flex-col gap-2 max-w-16">
<div className="w-20 aspect-video bg-muted rounded-md relative flex flex-col items-center justify-center">
{contentType ? (
contentType.startsWith("image") ? (
contentType.startsWith('image') ? (
// NOTE: it is recommended to use next/image for images
// eslint-disable-next-line @next/next/no-img-element
(<img
<img
key={url}
src={url}
alt={name ?? "An image attachment"}
alt={name ?? 'An image attachment'}
className="rounded-md size-full object-cover"
/>)
/>
) : (
<div className=""></div>
)
@ -38,6 +38,6 @@ export const PreviewAttachment = ({
)}
</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 { type ThemeProviderProps } from "next-themes/dist/types";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { type ThemeProviderProps } from 'next-themes/dist/types';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <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 { format, isWithinInterval } from "date-fns";
import { useEffect, useState } from "react";
import cx from 'classnames';
import { format, isWithinInterval } from 'date-fns';
import { useEffect, useState } from 'react';
interface WeatherAtLocation {
latitude: number;
@ -47,114 +47,114 @@ const SAMPLE = {
longitude: -122.41286,
generationtime_ms: 0.027894973754882812,
utc_offset_seconds: 0,
timezone: "GMT",
timezone_abbreviation: "GMT",
timezone: 'GMT',
timezone_abbreviation: 'GMT',
elevation: 18,
current_units: { time: "iso8601", interval: "seconds", temperature_2m: "°C" },
current: { time: "2024-10-07T19:30", interval: 900, temperature_2m: 29.3 },
hourly_units: { time: "iso8601", temperature_2m: "°C" },
current_units: { time: 'iso8601', interval: 'seconds', temperature_2m: '°C' },
current: { time: '2024-10-07T19:30', interval: 900, temperature_2m: 29.3 },
hourly_units: { time: 'iso8601', temperature_2m: '°C' },
hourly: {
time: [
"2024-10-07T00:00",
"2024-10-07T01:00",
"2024-10-07T02:00",
"2024-10-07T03:00",
"2024-10-07T04:00",
"2024-10-07T05:00",
"2024-10-07T06:00",
"2024-10-07T07:00",
"2024-10-07T08:00",
"2024-10-07T09:00",
"2024-10-07T10:00",
"2024-10-07T11:00",
"2024-10-07T12:00",
"2024-10-07T13:00",
"2024-10-07T14:00",
"2024-10-07T15:00",
"2024-10-07T16:00",
"2024-10-07T17:00",
"2024-10-07T18:00",
"2024-10-07T19:00",
"2024-10-07T20:00",
"2024-10-07T21:00",
"2024-10-07T22:00",
"2024-10-07T23:00",
"2024-10-08T00:00",
"2024-10-08T01:00",
"2024-10-08T02:00",
"2024-10-08T03:00",
"2024-10-08T04:00",
"2024-10-08T05:00",
"2024-10-08T06:00",
"2024-10-08T07:00",
"2024-10-08T08:00",
"2024-10-08T09:00",
"2024-10-08T10:00",
"2024-10-08T11:00",
"2024-10-08T12:00",
"2024-10-08T13:00",
"2024-10-08T14:00",
"2024-10-08T15:00",
"2024-10-08T16:00",
"2024-10-08T17:00",
"2024-10-08T18:00",
"2024-10-08T19:00",
"2024-10-08T20:00",
"2024-10-08T21:00",
"2024-10-08T22:00",
"2024-10-08T23:00",
"2024-10-09T00:00",
"2024-10-09T01:00",
"2024-10-09T02:00",
"2024-10-09T03:00",
"2024-10-09T04:00",
"2024-10-09T05:00",
"2024-10-09T06:00",
"2024-10-09T07:00",
"2024-10-09T08:00",
"2024-10-09T09:00",
"2024-10-09T10:00",
"2024-10-09T11:00",
"2024-10-09T12:00",
"2024-10-09T13:00",
"2024-10-09T14:00",
"2024-10-09T15:00",
"2024-10-09T16:00",
"2024-10-09T17:00",
"2024-10-09T18:00",
"2024-10-09T19:00",
"2024-10-09T20:00",
"2024-10-09T21:00",
"2024-10-09T22:00",
"2024-10-09T23:00",
"2024-10-10T00:00",
"2024-10-10T01:00",
"2024-10-10T02:00",
"2024-10-10T03:00",
"2024-10-10T04:00",
"2024-10-10T05:00",
"2024-10-10T06:00",
"2024-10-10T07:00",
"2024-10-10T08:00",
"2024-10-10T09:00",
"2024-10-10T10:00",
"2024-10-10T11:00",
"2024-10-10T12:00",
"2024-10-10T13:00",
"2024-10-10T14:00",
"2024-10-10T15:00",
"2024-10-10T16:00",
"2024-10-10T17:00",
"2024-10-10T18:00",
"2024-10-10T19:00",
"2024-10-10T20:00",
"2024-10-10T21:00",
"2024-10-10T22:00",
"2024-10-10T23:00",
"2024-10-11T00:00",
"2024-10-11T01:00",
"2024-10-11T02:00",
"2024-10-11T03:00",
'2024-10-07T00:00',
'2024-10-07T01:00',
'2024-10-07T02:00',
'2024-10-07T03:00',
'2024-10-07T04:00',
'2024-10-07T05:00',
'2024-10-07T06:00',
'2024-10-07T07:00',
'2024-10-07T08:00',
'2024-10-07T09:00',
'2024-10-07T10:00',
'2024-10-07T11:00',
'2024-10-07T12:00',
'2024-10-07T13:00',
'2024-10-07T14:00',
'2024-10-07T15:00',
'2024-10-07T16:00',
'2024-10-07T17:00',
'2024-10-07T18:00',
'2024-10-07T19:00',
'2024-10-07T20:00',
'2024-10-07T21:00',
'2024-10-07T22:00',
'2024-10-07T23:00',
'2024-10-08T00:00',
'2024-10-08T01:00',
'2024-10-08T02:00',
'2024-10-08T03:00',
'2024-10-08T04:00',
'2024-10-08T05:00',
'2024-10-08T06:00',
'2024-10-08T07:00',
'2024-10-08T08:00',
'2024-10-08T09:00',
'2024-10-08T10:00',
'2024-10-08T11:00',
'2024-10-08T12:00',
'2024-10-08T13:00',
'2024-10-08T14:00',
'2024-10-08T15:00',
'2024-10-08T16:00',
'2024-10-08T17:00',
'2024-10-08T18:00',
'2024-10-08T19:00',
'2024-10-08T20:00',
'2024-10-08T21:00',
'2024-10-08T22:00',
'2024-10-08T23:00',
'2024-10-09T00:00',
'2024-10-09T01:00',
'2024-10-09T02:00',
'2024-10-09T03:00',
'2024-10-09T04:00',
'2024-10-09T05:00',
'2024-10-09T06:00',
'2024-10-09T07:00',
'2024-10-09T08:00',
'2024-10-09T09:00',
'2024-10-09T10:00',
'2024-10-09T11:00',
'2024-10-09T12:00',
'2024-10-09T13:00',
'2024-10-09T14:00',
'2024-10-09T15:00',
'2024-10-09T16:00',
'2024-10-09T17:00',
'2024-10-09T18:00',
'2024-10-09T19:00',
'2024-10-09T20:00',
'2024-10-09T21:00',
'2024-10-09T22:00',
'2024-10-09T23:00',
'2024-10-10T00:00',
'2024-10-10T01:00',
'2024-10-10T02:00',
'2024-10-10T03:00',
'2024-10-10T04:00',
'2024-10-10T05:00',
'2024-10-10T06:00',
'2024-10-10T07:00',
'2024-10-10T08:00',
'2024-10-10T09:00',
'2024-10-10T10:00',
'2024-10-10T11:00',
'2024-10-10T12:00',
'2024-10-10T13:00',
'2024-10-10T14:00',
'2024-10-10T15:00',
'2024-10-10T16:00',
'2024-10-10T17:00',
'2024-10-10T18:00',
'2024-10-10T19:00',
'2024-10-10T20:00',
'2024-10-10T21:00',
'2024-10-10T22:00',
'2024-10-10T23:00',
'2024-10-11T00:00',
'2024-10-11T01:00',
'2024-10-11T02:00',
'2024-10-11T03:00',
],
temperature_2m: [
36.6, 32.8, 29.5, 28.6, 29.2, 28.2, 27.5, 26.6, 26.5, 26, 25, 23.5, 23.9,
@ -168,31 +168,31 @@ const SAMPLE = {
],
},
daily_units: {
time: "iso8601",
sunrise: "iso8601",
sunset: "iso8601",
time: 'iso8601',
sunrise: 'iso8601',
sunset: 'iso8601',
},
daily: {
time: [
"2024-10-07",
"2024-10-08",
"2024-10-09",
"2024-10-10",
"2024-10-11",
'2024-10-07',
'2024-10-08',
'2024-10-09',
'2024-10-10',
'2024-10-11',
],
sunrise: [
"2024-10-07T07:15",
"2024-10-08T07:16",
"2024-10-09T07:17",
"2024-10-10T07:18",
"2024-10-11T07:19",
'2024-10-07T07:15',
'2024-10-08T07:16',
'2024-10-09T07:17',
'2024-10-10T07:18',
'2024-10-11T07:19',
],
sunset: [
"2024-10-07T19:00",
"2024-10-08T18:58",
"2024-10-09T18:57",
"2024-10-10T18:55",
"2024-10-11T18:54",
'2024-10-07T19:00',
'2024-10-08T18:58',
'2024-10-09T18:57',
'2024-10-10T18:55',
'2024-10-11T18:54',
],
},
};
@ -207,10 +207,10 @@ export function Weather({
weatherAtLocation?: WeatherAtLocation;
}) {
const currentHigh = Math.max(
...weatherAtLocation.hourly.temperature_2m.slice(0, 24),
...weatherAtLocation.hourly.temperature_2m.slice(0, 24)
);
const currentLow = Math.min(
...weatherAtLocation.hourly.temperature_2m.slice(0, 24),
...weatherAtLocation.hourly.temperature_2m.slice(0, 24)
);
const isDay = isWithinInterval(new Date(weatherAtLocation.current.time), {
@ -226,51 +226,51 @@ export function Weather({
};
handleResize();
window.addEventListener("resize", handleResize);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener("resize", handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const hoursToShow = isMobile ? 5 : 6;
// Find the index of the current time or the next closest time
const currentTimeIndex = weatherAtLocation.hourly.time.findIndex(
(time) => new Date(time) >= new Date(weatherAtLocation.current.time),
(time) => new Date(time) >= new Date(weatherAtLocation.current.time)
);
// Slice the arrays to get the desired number of items
const displayTimes = weatherAtLocation.hourly.time.slice(
currentTimeIndex,
currentTimeIndex + hoursToShow,
currentTimeIndex + hoursToShow
);
const displayTemperatures = weatherAtLocation.hourly.temperature_2m.slice(
currentTimeIndex,
currentTimeIndex + hoursToShow,
currentTimeIndex + hoursToShow
);
return (
<div
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 gap-2 items-center">
<div
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">
@ -286,17 +286,17 @@ export function Weather({
{displayTimes.map((time, index) => (
<div key={time} className="flex flex-col items-center gap-1">
<div className="text-blue-100 text-xs">
{format(new Date(time), "ha")}
{format(new Date(time), 'ha')}
</div>
<div
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">

View file

@ -1,56 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
'inline-flex items-center gap-2 justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: "default",
size: "default",
variant: 'default',
size: 'default',
},
}
)
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...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 DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
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",
inset && "pl-8",
'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',
className
)}
{...props}
@ -36,9 +36,9 @@ const DropdownMenuSubTrigger = React.forwardRef<
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@ -47,14 +47,14 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
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
)}
{...props}
/>
))
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@ -65,32 +65,32 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
'z-50 min-w-[8rem] overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
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",
inset && "pl-8",
'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',
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
@ -99,7 +99,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
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
)}
checked={checked}
@ -112,9 +112,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@ -123,7 +123,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
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
)}
{...props}
@ -135,26 +135,26 @@ const DropdownMenuRadioItem = React.forwardRef<
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
@ -162,11 +162,11 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
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}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
@ -174,12 +174,12 @@ const DropdownMenuShortcut = ({
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
@ -197,4 +197,4 @@ export {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
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 = {
/* config options here */
images: {
remotePatterns: [],
remotePatterns: [
{
hostname: 'avatar.vercel.sh',
},
],
},
};

View file

@ -15,6 +15,8 @@
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-icons": "^1.3.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-tooltip": "^1.1.2",
"@radix-ui/react-visually-hidden": "^1.1.0",

98
pnpm-lock.yaml generated
View file

@ -26,6 +26,12 @@ importers:
'@radix-ui/react-label':
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)
'@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':
specifier: ^1.1.0
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':
resolution: {integrity: sha512-AHy0vjc+n/4w/8Mif+w86qpppHuF3AyXbcWW+R/W7GNA3F5/p2nuhlkCJaTXSLZheB4l1rtHzOfr9A7NwoR/Zg==}
'@emnapi/runtime@1.2.0':
resolution: {integrity: sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==}
'@emnapi/runtime@1.3.1':
resolution: {integrity: sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==}
'@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
@ -985,6 +991,9 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@radix-ui/number@1.1.0':
resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==}
'@radix-ui/primitive@1.1.0':
resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==}
@ -1277,6 +1286,32 @@ packages:
'@types/react-dom':
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':
resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==}
peerDependencies:
@ -1335,6 +1370,15 @@ packages:
'@types/react':
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':
resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==}
peerDependencies:
@ -3794,7 +3838,7 @@ snapshots:
'@drizzle-team/brocli@0.10.1': {}
'@emnapi/runtime@1.2.0':
'@emnapi/runtime@1.3.1':
dependencies:
tslib: 2.6.3
optional: true
@ -4136,7 +4180,7 @@ snapshots:
'@img/sharp-wasm32@0.33.5':
dependencies:
'@emnapi/runtime': 1.2.0
'@emnapi/runtime': 1.3.1
optional: true
'@img/sharp-win32-ia32@0.33.5':
@ -4226,6 +4270,8 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
'@radix-ui/number@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)':
@ -4503,6 +4549,44 @@ snapshots:
'@types/react': 18.3.3
'@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)':
dependencies:
'@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:
'@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)':
dependencies:
'@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 = {
darkMode: ["class"],
darkMode: ['class'],
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
fontFamily: {
sans: ["geist"],
mono: ["geist-mono"],
sans: ['geist'],
mono: ['geist-mono'],
},
extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
"1": "hsl(var(--chart-1))",
"2": "hsl(var(--chart-2))",
"3": "hsl(var(--chart-3))",
"4": "hsl(var(--chart-4))",
"5": "hsl(var(--chart-5))",
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'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")],
safelist: ["w-32", "w-44", "w-52"],
plugins: [require('tailwindcss-animate')],
safelist: ['w-32', 'w-44', 'w-52'],
};
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"]
}