parent
7faa5f1c9f
commit
a68eb2a011
41 changed files with 2350 additions and 800 deletions
17
ai/index.ts
17
ai/index.ts
|
|
@ -1,8 +1,13 @@
|
||||||
import { openai } from "@ai-sdk/openai";
|
import { openai } from '@ai-sdk/openai';
|
||||||
import { experimental_wrapLanguageModel as wrapLanguageModel } from "ai";
|
import { experimental_wrapLanguageModel as wrapLanguageModel } from 'ai';
|
||||||
import { customMiddleware } from "./custom-middleware";
|
|
||||||
|
|
||||||
export const customModel = wrapLanguageModel({
|
import { type Model } from '@/lib/model';
|
||||||
model: openai("gpt-4o"),
|
|
||||||
|
import { customMiddleware } from './custom-middleware';
|
||||||
|
|
||||||
|
export const customModel = (modelName: Model['name']) => {
|
||||||
|
return wrapLanguageModel({
|
||||||
|
model: openai(modelName),
|
||||||
middleware: customMiddleware,
|
middleware: customMiddleware,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
|
||||||
8
app/(chat)/actions.ts
Normal file
8
app/(chat)/actions.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -1,38 +1,47 @@
|
||||||
import { convertToCoreMessages, Message, streamText } from "ai";
|
import { convertToCoreMessages, Message, streamText } from 'ai';
|
||||||
import { z } from "zod";
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { customModel } from "@/ai";
|
import { customModel } from '@/ai';
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from '@/app/(auth)/auth';
|
||||||
import { deleteChatById, getChatById, saveChat } from "@/db/queries";
|
import { deleteChatById, getChatById, saveChat } from '@/db/queries';
|
||||||
|
import { Model, models } from '@/lib/model';
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const { id, messages }: { id: string; messages: Array<Message> } =
|
const {
|
||||||
|
id,
|
||||||
|
messages,
|
||||||
|
model,
|
||||||
|
}: { id: string; messages: Array<Message>; model: Model['name'] } =
|
||||||
await request.json();
|
await request.json();
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return new Response("Unauthorized", { status: 401 });
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!models.find((m) => m.name === model)) {
|
||||||
|
return new Response('Model not found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const coreMessages = convertToCoreMessages(messages);
|
const coreMessages = convertToCoreMessages(messages);
|
||||||
|
|
||||||
const result = await streamText({
|
const result = await streamText({
|
||||||
model: customModel,
|
model: customModel(model),
|
||||||
system:
|
system:
|
||||||
"you are a friendly assistant! keep your responses concise and helpful.",
|
'you are a friendly assistant! keep your responses concise and helpful.',
|
||||||
messages: coreMessages,
|
messages: coreMessages,
|
||||||
maxSteps: 5,
|
maxSteps: 5,
|
||||||
tools: {
|
tools: {
|
||||||
getWeather: {
|
getWeather: {
|
||||||
description: "Get the current weather at a location",
|
description: 'Get the current weather at a location',
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
latitude: z.number(),
|
latitude: z.number(),
|
||||||
longitude: z.number(),
|
longitude: z.number(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ latitude, longitude }) => {
|
execute: async ({ latitude, longitude }) => {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m&hourly=temperature_2m&daily=sunrise,sunset&timezone=auto`,
|
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m&hourly=temperature_2m&daily=sunrise,sunset&timezone=auto`
|
||||||
);
|
);
|
||||||
|
|
||||||
const weatherData = await response.json();
|
const weatherData = await response.json();
|
||||||
|
|
@ -49,13 +58,13 @@ export async function POST(request: Request) {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save chat");
|
console.error('Failed to save chat');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
experimental_telemetry: {
|
experimental_telemetry: {
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
functionId: "stream-text",
|
functionId: 'stream-text',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -64,30 +73,30 @@ export async function POST(request: Request) {
|
||||||
|
|
||||||
export async function DELETE(request: Request) {
|
export async function DELETE(request: Request) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const id = searchParams.get("id");
|
const id = searchParams.get('id');
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return new Response("Not Found", { status: 404 });
|
return new Response('Not Found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session || !session.user) {
|
if (!session || !session.user) {
|
||||||
return new Response("Unauthorized", { status: 401 });
|
return new Response('Unauthorized', { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const chat = await getChatById({ id });
|
const chat = await getChatById({ id });
|
||||||
|
|
||||||
if (chat.userId !== session.user.id) {
|
if (chat.userId !== session.user.id) {
|
||||||
return new Response("Unauthorized", { status: 401 });
|
return new Response('Unauthorized', { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await deleteChatById({ id });
|
await deleteChatById({ id });
|
||||||
|
|
||||||
return new Response("Chat deleted", { status: 200 });
|
return new Response('Chat deleted', { status: 200 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return new Response("An error occurred while processing your request", {
|
return new Response('An error occurred while processing your request', {
|
||||||
status: 500,
|
status: 500,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { CoreMessage } from "ai";
|
import { CoreMessage } from 'ai';
|
||||||
import { notFound } from "next/navigation";
|
import { cookies } from 'next/headers';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from '@/app/(auth)/auth';
|
||||||
import { Chat as PreviewChat } from "@/components/custom/chat";
|
import { Chat as PreviewChat } from '@/components/custom/chat';
|
||||||
import { getChatById } from "@/db/queries";
|
import { getChatById } from '@/db/queries';
|
||||||
import { Chat } from "@/db/schema";
|
import { Chat } from '@/db/schema';
|
||||||
import { convertToUIMessages, generateUUID } from "@/lib/utils";
|
import { DEFAULT_MODEL_NAME, models } from '@/lib/model';
|
||||||
|
import { convertToUIMessages } from '@/lib/utils';
|
||||||
|
|
||||||
export default async function Page(props: { params: Promise<any> }) {
|
export default async function Page(props: { params: Promise<any> }) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
@ -32,5 +34,16 @@ export default async function Page(props: { params: Promise<any> }) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return <PreviewChat id={chat.id} initialMessages={chat.messages} />;
|
const cookieStore = await cookies();
|
||||||
|
const value = cookieStore.get('model')?.value;
|
||||||
|
const selectedModelName =
|
||||||
|
models.find((m) => m.name === value)?.name || DEFAULT_MODEL_NAME;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PreviewChat
|
||||||
|
id={chat.id}
|
||||||
|
initialMessages={chat.messages}
|
||||||
|
selectedModelName={selectedModelName}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
app/(chat)/layout.tsx
Normal file
22
app/(chat)/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,23 @@
|
||||||
import { Chat } from "@/components/custom/chat";
|
import { cookies } from 'next/headers';
|
||||||
import { generateUUID } from "@/lib/utils";
|
|
||||||
|
import { Chat } from '@/components/custom/chat';
|
||||||
|
import { DEFAULT_MODEL_NAME, models } from '@/lib/model';
|
||||||
|
import { generateUUID } from '@/lib/utils';
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
const id = generateUUID();
|
const id = generateUUID();
|
||||||
return <Chat key={id} id={id} initialMessages={[]} />;
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const value = cookieStore.get('model')?.value;
|
||||||
|
const selectedModelName =
|
||||||
|
models.find((m) => m.name === value)?.name || DEFAULT_MODEL_NAME;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Chat
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
initialMessages={[]}
|
||||||
|
selectedModelName={selectedModelName}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,14 @@
|
||||||
--chart-4: 43 74% 66%;
|
--chart-4: 43 74% 66%;
|
||||||
--chart-5: 27 87% 67%;
|
--chart-5: 27 87% 67%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
--sidebar-background: 0 0% 98%;
|
||||||
|
--sidebar-foreground: 240 10% 3.9%;
|
||||||
|
--sidebar-primary: 240 5.9% 10%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 98%;
|
||||||
|
--sidebar-accent: 240 5.9% 94%;
|
||||||
|
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||||
|
--sidebar-border: 220 13% 91%;
|
||||||
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
}
|
}
|
||||||
.dark {
|
.dark {
|
||||||
--background: 240 10% 3.9%;
|
--background: 240 10% 3.9%;
|
||||||
|
|
@ -75,6 +83,14 @@
|
||||||
--chart-3: 30 80% 55%;
|
--chart-3: 30 80% 55%;
|
||||||
--chart-4: 280 65% 60%;
|
--chart-4: 280 65% 60%;
|
||||||
--chart-5: 340 75% 55%;
|
--chart-5: 340 75% 55%;
|
||||||
|
--sidebar-background: 240 5.9% 10%;
|
||||||
|
--sidebar-foreground: 240 4.8% 95.9%;
|
||||||
|
--sidebar-primary: 224.3 76.3% 48%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 240 3.7% 15.9%;
|
||||||
|
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||||
|
--sidebar-border: 240 3.7% 15.9%;
|
||||||
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,17 +104,17 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "geist";
|
font-family: 'geist';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 100 900;
|
font-weight: 100 900;
|
||||||
src: url(/fonts/geist.woff2) format("woff2");
|
src: url(/fonts/geist.woff2) format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "geist-mono";
|
font-family: 'geist-mono';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 100 900;
|
font-weight: 100 900;
|
||||||
src: url(/fonts/geist-mono.woff2) format("woff2");
|
src: url(/fonts/geist-mono.woff2) format('woff2');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,7 +123,7 @@
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
*[class^="text-"] {
|
*[class^='text-'] {
|
||||||
color: transparent;
|
color: transparent;
|
||||||
@apply rounded-md bg-foreground/20 select-none animate-pulse;
|
@apply rounded-md bg-foreground/20 select-none animate-pulse;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
|
|
||||||
import { Navbar } from '@/components/custom/navbar';
|
|
||||||
import { ThemeProvider } from '@/components/custom/theme-provider';
|
import { ThemeProvider } from '@/components/custom/theme-provider';
|
||||||
|
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
@ -65,7 +64,6 @@ export default async function RootLayout({
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" />
|
||||||
<Navbar />
|
|
||||||
{children}
|
{children}
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
99
components/custom/app-sidebar.tsx
Normal file
99
components/custom/app-sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
components/custom/chat-header.tsx
Normal file
36
components/custom/chat-header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,28 +1,32 @@
|
||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
import { Attachment, Message } from "ai";
|
import { Attachment, Message } from 'ai';
|
||||||
import { useChat } from "ai/react";
|
import { useChat } from 'ai/react';
|
||||||
import { useState } from "react";
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Message as PreviewMessage } from "@/components/custom/message";
|
import { ChatHeader } from '@/components/custom/chat-header';
|
||||||
import { useScrollToBottom } from "@/components/custom/use-scroll-to-bottom";
|
import { Message as PreviewMessage } from '@/components/custom/message';
|
||||||
|
import { useScrollToBottom } from '@/components/custom/use-scroll-to-bottom';
|
||||||
|
import { Model } from '@/lib/model';
|
||||||
|
|
||||||
import { MultimodalInput } from "./multimodal-input";
|
import { MultimodalInput } from './multimodal-input';
|
||||||
import { Overview } from "./overview";
|
import { Overview } from './overview';
|
||||||
|
|
||||||
export function Chat({
|
export function Chat({
|
||||||
id,
|
id,
|
||||||
initialMessages,
|
initialMessages,
|
||||||
|
selectedModelName,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
initialMessages: Array<Message>;
|
initialMessages: Array<Message>;
|
||||||
|
selectedModelName: Model['name'];
|
||||||
}) {
|
}) {
|
||||||
const { messages, handleSubmit, input, setInput, append, isLoading, stop } =
|
const { messages, handleSubmit, input, setInput, append, isLoading, stop } =
|
||||||
useChat({
|
useChat({
|
||||||
body: { id },
|
body: { id, model: selectedModelName },
|
||||||
initialMessages,
|
initialMessages,
|
||||||
onFinish: () => {
|
onFinish: () => {
|
||||||
window.history.replaceState({}, "", `/chat/${id}`);
|
window.history.replaceState({}, '', `/chat/${id}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -32,11 +36,11 @@ export function Chat({
|
||||||
const [attachments, setAttachments] = useState<Array<Attachment>>([]);
|
const [attachments, setAttachments] = useState<Array<Attachment>>([]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row justify-center pb-4 md:pb-8 h-dvh bg-background">
|
<div className="flex flex-col min-w-0 h-dvh bg-background">
|
||||||
<div className="flex flex-col justify-between items-center gap-4">
|
<ChatHeader selectedModelName={selectedModelName} />
|
||||||
<div
|
<div
|
||||||
ref={messagesContainerRef}
|
ref={messagesContainerRef}
|
||||||
className="flex flex-col gap-4 h-full w-dvw items-center overflow-y-scroll"
|
className="flex flex-col min-w-0 gap-6 flex-1 overflow-y-scroll"
|
||||||
>
|
>
|
||||||
{messages.length === 0 && <Overview />}
|
{messages.length === 0 && <Overview />}
|
||||||
|
|
||||||
|
|
@ -55,8 +59,7 @@ export function Chat({
|
||||||
className="shrink-0 min-w-[24px] min-h-[24px]"
|
className="shrink-0 min-w-[24px] min-h-[24px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<form className="flex mx-auto px-4 bg-background pb-4 md:pb-6 gap-2 w-full md:max-w-3xl">
|
||||||
<form className="flex flex-row gap-2 relative items-end w-full md:max-w-[500px] max-w-[calc(100dvw-32px) px-4 md:px-0">
|
|
||||||
<MultimodalInput
|
<MultimodalInput
|
||||||
input={input}
|
input={input}
|
||||||
setInput={setInput}
|
setInput={setInput}
|
||||||
|
|
@ -70,6 +73,5 @@ export function Chat({
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
import { Attachment, ToolInvocation } from "ai";
|
import { Attachment, ToolInvocation } from 'ai';
|
||||||
import { motion } from "framer-motion";
|
import { motion } from 'framer-motion';
|
||||||
import { ReactNode } from "react";
|
import { Sparkles } from 'lucide-react';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
import { BotIcon, UserIcon } from "./icons";
|
import { Markdown } from './markdown';
|
||||||
import { Markdown } from "./markdown";
|
import { PreviewAttachment } from './preview-attachment';
|
||||||
import { PreviewAttachment } from "./preview-attachment";
|
import { Weather } from './weather';
|
||||||
import { Weather } from "./weather";
|
|
||||||
|
|
||||||
export const Message = ({
|
export const Message = ({
|
||||||
role,
|
role,
|
||||||
|
|
@ -22,32 +22,35 @@ export const Message = ({
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className={`flex flex-row gap-4 px-4 w-full md:w-[500px] md:px-0 first-of-type:pt-20`}
|
className="w-full mx-auto max-w-3xl px-4 group/message "
|
||||||
initial={{ y: 5, opacity: 0 }}
|
initial={{ y: 5, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
data-role={role}
|
||||||
>
|
>
|
||||||
<div className="size-[24px] flex flex-col justify-center items-center shrink-0 text-zinc-400">
|
<div className="flex gap-4 group-data-[role=user]/message:px-5 w-full group-data-[role=user]/message:w-fit group-data-[role=user]/message:ml-auto group-data-[role=user]/message:max-w-2xl group-data-[role=user]/message:py-3.5 group-data-[role=user]/message:bg-muted rounded-xl">
|
||||||
{role === "assistant" ? <BotIcon /> : <UserIcon />}
|
{role === 'assistant' && (
|
||||||
|
<div className="size-8 flex items-center rounded-full justify-center ring-1 shrink-0 ring-border">
|
||||||
|
<Sparkles className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex flex-col gap-2 w-full">
|
<div className="flex flex-col gap-2 w-full">
|
||||||
{content && (
|
{content && (
|
||||||
<div className="text-zinc-800 dark:text-zinc-300 flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Markdown>{content as string}</Markdown>
|
<Markdown>{content as string}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{toolInvocations && (
|
{toolInvocations && toolInvocations.length > 0 ? (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{toolInvocations.map((toolInvocation) => {
|
{toolInvocations.map((toolInvocation) => {
|
||||||
const { toolName, toolCallId, state } = toolInvocation;
|
const { toolName, toolCallId, state } = toolInvocation;
|
||||||
|
|
||||||
if (state === "result") {
|
if (state === 'result') {
|
||||||
const { result } = toolInvocation;
|
const { result } = toolInvocation;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={toolCallId}>
|
<div key={toolCallId}>
|
||||||
{toolName === "getWeather" ? (
|
{toolName === 'getWeather' ? (
|
||||||
<Weather weatherAtLocation={result} />
|
<Weather weatherAtLocation={result} />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -55,22 +58,26 @@ export const Message = ({
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<div key={toolCallId} className="skeleton">
|
<div key={toolCallId} className="skeleton">
|
||||||
{toolName === "getWeather" ? <Weather /> : null}
|
{toolName === 'getWeather' ? <Weather /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{attachments && (
|
{attachments && (
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
{attachments.map((attachment) => (
|
{attachments.map((attachment) => (
|
||||||
<PreviewAttachment key={attachment.url} attachment={attachment} />
|
<PreviewAttachment
|
||||||
|
key={attachment.url}
|
||||||
|
attachment={attachment}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
75
components/custom/model-selector.tsx
Normal file
75
components/custom/model-selector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
import { Attachment, ChatRequestOptions, CreateMessage, Message } from "ai";
|
import { Attachment, ChatRequestOptions, CreateMessage, Message } from 'ai';
|
||||||
import { motion } from "framer-motion";
|
import { motion } from 'framer-motion';
|
||||||
import React, {
|
import React, {
|
||||||
useRef,
|
useRef,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
|
@ -10,24 +10,24 @@ import React, {
|
||||||
Dispatch,
|
Dispatch,
|
||||||
SetStateAction,
|
SetStateAction,
|
||||||
ChangeEvent,
|
ChangeEvent,
|
||||||
} from "react";
|
} from 'react';
|
||||||
import { toast } from "sonner";
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { ArrowUpIcon, PaperclipIcon, StopIcon } from "./icons";
|
import { ArrowUpIcon, PaperclipIcon, StopIcon } from './icons';
|
||||||
import { PreviewAttachment } from "./preview-attachment";
|
import { PreviewAttachment } from './preview-attachment';
|
||||||
import useWindowSize from "./use-window-size";
|
import useWindowSize from './use-window-size';
|
||||||
import { Button } from "../ui/button";
|
import { Button } from '../ui/button';
|
||||||
import { Textarea } from "../ui/textarea";
|
import { Textarea } from '../ui/textarea';
|
||||||
|
|
||||||
const suggestedActions = [
|
const suggestedActions = [
|
||||||
{
|
{
|
||||||
title: "What is the weather",
|
title: 'What is the weather',
|
||||||
label: "in San Francisco?",
|
label: 'in San Francisco?',
|
||||||
action: "What is the weather in San Francisco?",
|
action: 'What is the weather in San Francisco?',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Answer like I'm 5,",
|
title: "Answer like I'm 5,",
|
||||||
label: "why is the sky blue?",
|
label: 'why is the sky blue?',
|
||||||
action: "Answer like I'm 5, why is the sky blue?",
|
action: "Answer like I'm 5, why is the sky blue?",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -52,13 +52,13 @@ export function MultimodalInput({
|
||||||
messages: Array<Message>;
|
messages: Array<Message>;
|
||||||
append: (
|
append: (
|
||||||
message: Message | CreateMessage,
|
message: Message | CreateMessage,
|
||||||
chatRequestOptions?: ChatRequestOptions,
|
chatRequestOptions?: ChatRequestOptions
|
||||||
) => Promise<string | null | undefined>;
|
) => Promise<string | null | undefined>;
|
||||||
handleSubmit: (
|
handleSubmit: (
|
||||||
event?: {
|
event?: {
|
||||||
preventDefault?: () => void;
|
preventDefault?: () => void;
|
||||||
},
|
},
|
||||||
chatRequestOptions?: ChatRequestOptions,
|
chatRequestOptions?: ChatRequestOptions
|
||||||
) => void;
|
) => void;
|
||||||
}) {
|
}) {
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
@ -72,7 +72,7 @@ export function MultimodalInput({
|
||||||
|
|
||||||
const adjustHeight = () => {
|
const adjustHeight = () => {
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.style.height = "auto";
|
textareaRef.current.style.height = 'auto';
|
||||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`;
|
textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -99,11 +99,11 @@ export function MultimodalInput({
|
||||||
|
|
||||||
const uploadFile = async (file: File) => {
|
const uploadFile = async (file: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append('file', file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/files/upload`, {
|
const response = await fetch(`/api/files/upload`, {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -121,7 +121,7 @@ export function MultimodalInput({
|
||||||
toast.error(error);
|
toast.error(error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Failed to upload file, please try again!");
|
toast.error('Failed to upload file, please try again!');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -135,7 +135,7 @@ export function MultimodalInput({
|
||||||
const uploadPromises = files.map((file) => uploadFile(file));
|
const uploadPromises = files.map((file) => uploadFile(file));
|
||||||
const uploadedAttachments = await Promise.all(uploadPromises);
|
const uploadedAttachments = await Promise.all(uploadPromises);
|
||||||
const successfullyUploadedAttachments = uploadedAttachments.filter(
|
const successfullyUploadedAttachments = uploadedAttachments.filter(
|
||||||
(attachment) => attachment !== undefined,
|
(attachment) => attachment !== undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
setAttachments((currentAttachments) => [
|
setAttachments((currentAttachments) => [
|
||||||
|
|
@ -143,12 +143,12 @@ export function MultimodalInput({
|
||||||
...successfullyUploadedAttachments,
|
...successfullyUploadedAttachments,
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error uploading files!", error);
|
console.error('Error uploading files!', error);
|
||||||
} finally {
|
} finally {
|
||||||
setUploadQueue([]);
|
setUploadQueue([]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setAttachments],
|
[setAttachments]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -156,7 +156,7 @@ export function MultimodalInput({
|
||||||
{messages.length === 0 &&
|
{messages.length === 0 &&
|
||||||
attachments.length === 0 &&
|
attachments.length === 0 &&
|
||||||
uploadQueue.length === 0 && (
|
uploadQueue.length === 0 && (
|
||||||
<div className="grid sm:grid-cols-2 gap-2 w-full md:px-0 mx-auto md:max-w-[500px]">
|
<div className="grid sm:grid-cols-2 gap-2 w-full">
|
||||||
{suggestedActions.map((suggestedAction, index) => (
|
{suggestedActions.map((suggestedAction, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
|
@ -164,22 +164,23 @@ export function MultimodalInput({
|
||||||
exit={{ opacity: 0, y: 20 }}
|
exit={{ opacity: 0, y: 20 }}
|
||||||
transition={{ delay: 0.05 * index }}
|
transition={{ delay: 0.05 * index }}
|
||||||
key={index}
|
key={index}
|
||||||
className={index > 1 ? "hidden sm:block" : "block"}
|
className={index > 1 ? 'hidden sm:block' : 'block'}
|
||||||
>
|
>
|
||||||
<button
|
<Button
|
||||||
|
variant="ghost"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
append({
|
append({
|
||||||
role: "user",
|
role: 'user',
|
||||||
content: suggestedAction.action,
|
content: suggestedAction.action,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="w-full text-left border border-zinc-200 dark:border-zinc-800 text-zinc-800 dark:text-zinc-300 rounded-lg p-2 text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors flex flex-col"
|
className="text-left border rounded-xl px-4 py-3.5 text-sm flex-1 gap-1 sm:flex-col w-full h-auto justify-start items-start"
|
||||||
>
|
>
|
||||||
<span className="font-medium">{suggestedAction.title}</span>
|
<span className="font-medium">{suggestedAction.title}</span>
|
||||||
<span className="text-zinc-500 dark:text-zinc-400">
|
<span className="text-muted-foreground">
|
||||||
{suggestedAction.label}
|
{suggestedAction.label}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -204,9 +205,9 @@ export function MultimodalInput({
|
||||||
<PreviewAttachment
|
<PreviewAttachment
|
||||||
key={filename}
|
key={filename}
|
||||||
attachment={{
|
attachment={{
|
||||||
url: "",
|
url: '',
|
||||||
name: filename,
|
name: filename,
|
||||||
contentType: "",
|
contentType: '',
|
||||||
}}
|
}}
|
||||||
isUploading={true}
|
isUploading={true}
|
||||||
/>
|
/>
|
||||||
|
|
@ -219,14 +220,14 @@ export function MultimodalInput({
|
||||||
placeholder="Send a message..."
|
placeholder="Send a message..."
|
||||||
value={input}
|
value={input}
|
||||||
onChange={handleInput}
|
onChange={handleInput}
|
||||||
className="min-h-[24px] overflow-hidden resize-none rounded-lg text-base bg-muted"
|
className="min-h-[24px] overflow-hidden resize-none rounded-xl p-4 focus-visible:ring-0 focus-visible:ring-offset-0 text-base bg-muted border-none"
|
||||||
rows={3}
|
rows={3}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Enter" && !event.shiftKey) {
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
toast.error("Please wait for the model to finish its response!");
|
toast.error('Please wait for the model to finish its response!');
|
||||||
} else {
|
} else {
|
||||||
submitForm();
|
submitForm();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,41 +1,40 @@
|
||||||
import { motion } from "framer-motion";
|
import { motion } from 'framer-motion';
|
||||||
import Link from "next/link";
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { LogoOpenAI, MessageIcon, VercelIcon } from "./icons";
|
import { MessageIcon, VercelIcon } from './icons';
|
||||||
|
|
||||||
export const Overview = () => {
|
export const Overview = () => {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="overview"
|
key="overview"
|
||||||
className="max-w-[500px] mt-20 mx-4 md:mx-0"
|
className="max-w-3xl mx-auto md:mt-20"
|
||||||
initial={{ opacity: 0, scale: 0.98 }}
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.98 }}
|
exit={{ opacity: 0, scale: 0.98 }}
|
||||||
transition={{ delay: 0.5 }}
|
transition={{ delay: 0.5 }}
|
||||||
>
|
>
|
||||||
<div className="border rounded-lg p-6 flex flex-col gap-4 text-zinc-500 text-sm dark:text-zinc-400 dark:border-zinc-700">
|
<div className="rounded-xl p-6 flex flex-col gap-8 leading-relaxed text-center max-w-xl">
|
||||||
<p className="flex flex-row justify-center gap-4 items-center text-zinc-900 dark:text-zinc-50">
|
<p className="flex flex-row justify-center gap-4 items-center">
|
||||||
<VercelIcon />
|
<VercelIcon size={32} />
|
||||||
<span>+</span>
|
<span>+</span>
|
||||||
<MessageIcon />
|
<MessageIcon size={32} />
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
This is an open source Chatbot template built with Next.js and the AI
|
This is an open source Chatbot template built with Next.js and the AI
|
||||||
SDK by Vercel. It uses the{" "}
|
SDK by Vercel. It uses the{' '}
|
||||||
<code className="rounded-md bg-muted px-1 py-0.5">streamText</code>{" "}
|
<code className="rounded-md bg-muted px-1 py-0.5">streamText</code>{' '}
|
||||||
function in the server and the{" "}
|
function in the server and the{' '}
|
||||||
<code className="rounded-md bg-muted px-1 py-0.5">useChat</code> hook
|
<code className="rounded-md bg-muted px-1 py-0.5">useChat</code> hook
|
||||||
on the client to create a seamless chat experience.
|
on the client to create a seamless chat experience.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{" "}
|
You can learn more about the AI SDK by visiting the{' '}
|
||||||
You can learn more about the AI SDK by visiting the{" "}
|
|
||||||
<Link
|
<Link
|
||||||
className="text-blue-500 dark:text-blue-400"
|
className="font-medium underline underline-offset-4"
|
||||||
href="https://sdk.vercel.ai/docs"
|
href="https://sdk.vercel.ai/docs"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
Docs
|
docs
|
||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Attachment } from "ai";
|
import { Attachment } from 'ai';
|
||||||
|
|
||||||
import { LoaderIcon } from "./icons";
|
import { LoaderIcon } from './icons';
|
||||||
|
|
||||||
export const PreviewAttachment = ({
|
export const PreviewAttachment = ({
|
||||||
attachment,
|
attachment,
|
||||||
|
|
@ -12,18 +12,18 @@ export const PreviewAttachment = ({
|
||||||
const { name, url, contentType } = attachment;
|
const { name, url, contentType } = attachment;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(<div className="flex flex-col gap-2 max-w-16">
|
<div className="flex flex-col gap-2 max-w-16">
|
||||||
<div className="h-20 w-16 bg-muted rounded-md relative flex flex-col items-center justify-center">
|
<div className="w-20 aspect-video bg-muted rounded-md relative flex flex-col items-center justify-center">
|
||||||
{contentType ? (
|
{contentType ? (
|
||||||
contentType.startsWith("image") ? (
|
contentType.startsWith('image') ? (
|
||||||
// NOTE: it is recommended to use next/image for images
|
// NOTE: it is recommended to use next/image for images
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
(<img
|
<img
|
||||||
key={url}
|
key={url}
|
||||||
src={url}
|
src={url}
|
||||||
alt={name ?? "An image attachment"}
|
alt={name ?? 'An image attachment'}
|
||||||
className="rounded-md size-full object-cover"
|
className="rounded-md size-full object-cover"
|
||||||
/>)
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className=""></div>
|
<div className=""></div>
|
||||||
)
|
)
|
||||||
|
|
@ -38,6 +38,6 @@ export const PreviewAttachment = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-zinc-500 max-w-16 truncate">{name}</div>
|
<div className="text-xs text-zinc-500 max-w-16 truncate">{name}</div>
|
||||||
</div>)
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
210
components/custom/sidebar-history.tsx
Normal file
210
components/custom/sidebar-history.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
components/custom/sidebar-toggle.tsx
Normal file
20
components/custom/sidebar-toggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
components/custom/sidebar-user-nav.tsx
Normal file
67
components/custom/sidebar-user-nav.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
components/custom/sign-out-form.tsx
Normal file
25
components/custom/sign-out-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
import { type ThemeProviderProps } from 'next-themes/dist/types';
|
||||||
import * as React from "react";
|
|
||||||
|
|
||||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
import cx from "classnames";
|
import cx from 'classnames';
|
||||||
import { format, isWithinInterval } from "date-fns";
|
import { format, isWithinInterval } from 'date-fns';
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
interface WeatherAtLocation {
|
interface WeatherAtLocation {
|
||||||
latitude: number;
|
latitude: number;
|
||||||
|
|
@ -47,114 +47,114 @@ const SAMPLE = {
|
||||||
longitude: -122.41286,
|
longitude: -122.41286,
|
||||||
generationtime_ms: 0.027894973754882812,
|
generationtime_ms: 0.027894973754882812,
|
||||||
utc_offset_seconds: 0,
|
utc_offset_seconds: 0,
|
||||||
timezone: "GMT",
|
timezone: 'GMT',
|
||||||
timezone_abbreviation: "GMT",
|
timezone_abbreviation: 'GMT',
|
||||||
elevation: 18,
|
elevation: 18,
|
||||||
current_units: { time: "iso8601", interval: "seconds", temperature_2m: "°C" },
|
current_units: { time: 'iso8601', interval: 'seconds', temperature_2m: '°C' },
|
||||||
current: { time: "2024-10-07T19:30", interval: 900, temperature_2m: 29.3 },
|
current: { time: '2024-10-07T19:30', interval: 900, temperature_2m: 29.3 },
|
||||||
hourly_units: { time: "iso8601", temperature_2m: "°C" },
|
hourly_units: { time: 'iso8601', temperature_2m: '°C' },
|
||||||
hourly: {
|
hourly: {
|
||||||
time: [
|
time: [
|
||||||
"2024-10-07T00:00",
|
'2024-10-07T00:00',
|
||||||
"2024-10-07T01:00",
|
'2024-10-07T01:00',
|
||||||
"2024-10-07T02:00",
|
'2024-10-07T02:00',
|
||||||
"2024-10-07T03:00",
|
'2024-10-07T03:00',
|
||||||
"2024-10-07T04:00",
|
'2024-10-07T04:00',
|
||||||
"2024-10-07T05:00",
|
'2024-10-07T05:00',
|
||||||
"2024-10-07T06:00",
|
'2024-10-07T06:00',
|
||||||
"2024-10-07T07:00",
|
'2024-10-07T07:00',
|
||||||
"2024-10-07T08:00",
|
'2024-10-07T08:00',
|
||||||
"2024-10-07T09:00",
|
'2024-10-07T09:00',
|
||||||
"2024-10-07T10:00",
|
'2024-10-07T10:00',
|
||||||
"2024-10-07T11:00",
|
'2024-10-07T11:00',
|
||||||
"2024-10-07T12:00",
|
'2024-10-07T12:00',
|
||||||
"2024-10-07T13:00",
|
'2024-10-07T13:00',
|
||||||
"2024-10-07T14:00",
|
'2024-10-07T14:00',
|
||||||
"2024-10-07T15:00",
|
'2024-10-07T15:00',
|
||||||
"2024-10-07T16:00",
|
'2024-10-07T16:00',
|
||||||
"2024-10-07T17:00",
|
'2024-10-07T17:00',
|
||||||
"2024-10-07T18:00",
|
'2024-10-07T18:00',
|
||||||
"2024-10-07T19:00",
|
'2024-10-07T19:00',
|
||||||
"2024-10-07T20:00",
|
'2024-10-07T20:00',
|
||||||
"2024-10-07T21:00",
|
'2024-10-07T21:00',
|
||||||
"2024-10-07T22:00",
|
'2024-10-07T22:00',
|
||||||
"2024-10-07T23:00",
|
'2024-10-07T23:00',
|
||||||
"2024-10-08T00:00",
|
'2024-10-08T00:00',
|
||||||
"2024-10-08T01:00",
|
'2024-10-08T01:00',
|
||||||
"2024-10-08T02:00",
|
'2024-10-08T02:00',
|
||||||
"2024-10-08T03:00",
|
'2024-10-08T03:00',
|
||||||
"2024-10-08T04:00",
|
'2024-10-08T04:00',
|
||||||
"2024-10-08T05:00",
|
'2024-10-08T05:00',
|
||||||
"2024-10-08T06:00",
|
'2024-10-08T06:00',
|
||||||
"2024-10-08T07:00",
|
'2024-10-08T07:00',
|
||||||
"2024-10-08T08:00",
|
'2024-10-08T08:00',
|
||||||
"2024-10-08T09:00",
|
'2024-10-08T09:00',
|
||||||
"2024-10-08T10:00",
|
'2024-10-08T10:00',
|
||||||
"2024-10-08T11:00",
|
'2024-10-08T11:00',
|
||||||
"2024-10-08T12:00",
|
'2024-10-08T12:00',
|
||||||
"2024-10-08T13:00",
|
'2024-10-08T13:00',
|
||||||
"2024-10-08T14:00",
|
'2024-10-08T14:00',
|
||||||
"2024-10-08T15:00",
|
'2024-10-08T15:00',
|
||||||
"2024-10-08T16:00",
|
'2024-10-08T16:00',
|
||||||
"2024-10-08T17:00",
|
'2024-10-08T17:00',
|
||||||
"2024-10-08T18:00",
|
'2024-10-08T18:00',
|
||||||
"2024-10-08T19:00",
|
'2024-10-08T19:00',
|
||||||
"2024-10-08T20:00",
|
'2024-10-08T20:00',
|
||||||
"2024-10-08T21:00",
|
'2024-10-08T21:00',
|
||||||
"2024-10-08T22:00",
|
'2024-10-08T22:00',
|
||||||
"2024-10-08T23:00",
|
'2024-10-08T23:00',
|
||||||
"2024-10-09T00:00",
|
'2024-10-09T00:00',
|
||||||
"2024-10-09T01:00",
|
'2024-10-09T01:00',
|
||||||
"2024-10-09T02:00",
|
'2024-10-09T02:00',
|
||||||
"2024-10-09T03:00",
|
'2024-10-09T03:00',
|
||||||
"2024-10-09T04:00",
|
'2024-10-09T04:00',
|
||||||
"2024-10-09T05:00",
|
'2024-10-09T05:00',
|
||||||
"2024-10-09T06:00",
|
'2024-10-09T06:00',
|
||||||
"2024-10-09T07:00",
|
'2024-10-09T07:00',
|
||||||
"2024-10-09T08:00",
|
'2024-10-09T08:00',
|
||||||
"2024-10-09T09:00",
|
'2024-10-09T09:00',
|
||||||
"2024-10-09T10:00",
|
'2024-10-09T10:00',
|
||||||
"2024-10-09T11:00",
|
'2024-10-09T11:00',
|
||||||
"2024-10-09T12:00",
|
'2024-10-09T12:00',
|
||||||
"2024-10-09T13:00",
|
'2024-10-09T13:00',
|
||||||
"2024-10-09T14:00",
|
'2024-10-09T14:00',
|
||||||
"2024-10-09T15:00",
|
'2024-10-09T15:00',
|
||||||
"2024-10-09T16:00",
|
'2024-10-09T16:00',
|
||||||
"2024-10-09T17:00",
|
'2024-10-09T17:00',
|
||||||
"2024-10-09T18:00",
|
'2024-10-09T18:00',
|
||||||
"2024-10-09T19:00",
|
'2024-10-09T19:00',
|
||||||
"2024-10-09T20:00",
|
'2024-10-09T20:00',
|
||||||
"2024-10-09T21:00",
|
'2024-10-09T21:00',
|
||||||
"2024-10-09T22:00",
|
'2024-10-09T22:00',
|
||||||
"2024-10-09T23:00",
|
'2024-10-09T23:00',
|
||||||
"2024-10-10T00:00",
|
'2024-10-10T00:00',
|
||||||
"2024-10-10T01:00",
|
'2024-10-10T01:00',
|
||||||
"2024-10-10T02:00",
|
'2024-10-10T02:00',
|
||||||
"2024-10-10T03:00",
|
'2024-10-10T03:00',
|
||||||
"2024-10-10T04:00",
|
'2024-10-10T04:00',
|
||||||
"2024-10-10T05:00",
|
'2024-10-10T05:00',
|
||||||
"2024-10-10T06:00",
|
'2024-10-10T06:00',
|
||||||
"2024-10-10T07:00",
|
'2024-10-10T07:00',
|
||||||
"2024-10-10T08:00",
|
'2024-10-10T08:00',
|
||||||
"2024-10-10T09:00",
|
'2024-10-10T09:00',
|
||||||
"2024-10-10T10:00",
|
'2024-10-10T10:00',
|
||||||
"2024-10-10T11:00",
|
'2024-10-10T11:00',
|
||||||
"2024-10-10T12:00",
|
'2024-10-10T12:00',
|
||||||
"2024-10-10T13:00",
|
'2024-10-10T13:00',
|
||||||
"2024-10-10T14:00",
|
'2024-10-10T14:00',
|
||||||
"2024-10-10T15:00",
|
'2024-10-10T15:00',
|
||||||
"2024-10-10T16:00",
|
'2024-10-10T16:00',
|
||||||
"2024-10-10T17:00",
|
'2024-10-10T17:00',
|
||||||
"2024-10-10T18:00",
|
'2024-10-10T18:00',
|
||||||
"2024-10-10T19:00",
|
'2024-10-10T19:00',
|
||||||
"2024-10-10T20:00",
|
'2024-10-10T20:00',
|
||||||
"2024-10-10T21:00",
|
'2024-10-10T21:00',
|
||||||
"2024-10-10T22:00",
|
'2024-10-10T22:00',
|
||||||
"2024-10-10T23:00",
|
'2024-10-10T23:00',
|
||||||
"2024-10-11T00:00",
|
'2024-10-11T00:00',
|
||||||
"2024-10-11T01:00",
|
'2024-10-11T01:00',
|
||||||
"2024-10-11T02:00",
|
'2024-10-11T02:00',
|
||||||
"2024-10-11T03:00",
|
'2024-10-11T03:00',
|
||||||
],
|
],
|
||||||
temperature_2m: [
|
temperature_2m: [
|
||||||
36.6, 32.8, 29.5, 28.6, 29.2, 28.2, 27.5, 26.6, 26.5, 26, 25, 23.5, 23.9,
|
36.6, 32.8, 29.5, 28.6, 29.2, 28.2, 27.5, 26.6, 26.5, 26, 25, 23.5, 23.9,
|
||||||
|
|
@ -168,31 +168,31 @@ const SAMPLE = {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
daily_units: {
|
daily_units: {
|
||||||
time: "iso8601",
|
time: 'iso8601',
|
||||||
sunrise: "iso8601",
|
sunrise: 'iso8601',
|
||||||
sunset: "iso8601",
|
sunset: 'iso8601',
|
||||||
},
|
},
|
||||||
daily: {
|
daily: {
|
||||||
time: [
|
time: [
|
||||||
"2024-10-07",
|
'2024-10-07',
|
||||||
"2024-10-08",
|
'2024-10-08',
|
||||||
"2024-10-09",
|
'2024-10-09',
|
||||||
"2024-10-10",
|
'2024-10-10',
|
||||||
"2024-10-11",
|
'2024-10-11',
|
||||||
],
|
],
|
||||||
sunrise: [
|
sunrise: [
|
||||||
"2024-10-07T07:15",
|
'2024-10-07T07:15',
|
||||||
"2024-10-08T07:16",
|
'2024-10-08T07:16',
|
||||||
"2024-10-09T07:17",
|
'2024-10-09T07:17',
|
||||||
"2024-10-10T07:18",
|
'2024-10-10T07:18',
|
||||||
"2024-10-11T07:19",
|
'2024-10-11T07:19',
|
||||||
],
|
],
|
||||||
sunset: [
|
sunset: [
|
||||||
"2024-10-07T19:00",
|
'2024-10-07T19:00',
|
||||||
"2024-10-08T18:58",
|
'2024-10-08T18:58',
|
||||||
"2024-10-09T18:57",
|
'2024-10-09T18:57',
|
||||||
"2024-10-10T18:55",
|
'2024-10-10T18:55',
|
||||||
"2024-10-11T18:54",
|
'2024-10-11T18:54',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -207,10 +207,10 @@ export function Weather({
|
||||||
weatherAtLocation?: WeatherAtLocation;
|
weatherAtLocation?: WeatherAtLocation;
|
||||||
}) {
|
}) {
|
||||||
const currentHigh = Math.max(
|
const currentHigh = Math.max(
|
||||||
...weatherAtLocation.hourly.temperature_2m.slice(0, 24),
|
...weatherAtLocation.hourly.temperature_2m.slice(0, 24)
|
||||||
);
|
);
|
||||||
const currentLow = Math.min(
|
const currentLow = Math.min(
|
||||||
...weatherAtLocation.hourly.temperature_2m.slice(0, 24),
|
...weatherAtLocation.hourly.temperature_2m.slice(0, 24)
|
||||||
);
|
);
|
||||||
|
|
||||||
const isDay = isWithinInterval(new Date(weatherAtLocation.current.time), {
|
const isDay = isWithinInterval(new Date(weatherAtLocation.current.time), {
|
||||||
|
|
@ -226,51 +226,51 @@ export function Weather({
|
||||||
};
|
};
|
||||||
|
|
||||||
handleResize();
|
handleResize();
|
||||||
window.addEventListener("resize", handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
return () => window.removeEventListener("resize", handleResize);
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const hoursToShow = isMobile ? 5 : 6;
|
const hoursToShow = isMobile ? 5 : 6;
|
||||||
|
|
||||||
// Find the index of the current time or the next closest time
|
// Find the index of the current time or the next closest time
|
||||||
const currentTimeIndex = weatherAtLocation.hourly.time.findIndex(
|
const currentTimeIndex = weatherAtLocation.hourly.time.findIndex(
|
||||||
(time) => new Date(time) >= new Date(weatherAtLocation.current.time),
|
(time) => new Date(time) >= new Date(weatherAtLocation.current.time)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Slice the arrays to get the desired number of items
|
// Slice the arrays to get the desired number of items
|
||||||
const displayTimes = weatherAtLocation.hourly.time.slice(
|
const displayTimes = weatherAtLocation.hourly.time.slice(
|
||||||
currentTimeIndex,
|
currentTimeIndex,
|
||||||
currentTimeIndex + hoursToShow,
|
currentTimeIndex + hoursToShow
|
||||||
);
|
);
|
||||||
const displayTemperatures = weatherAtLocation.hourly.temperature_2m.slice(
|
const displayTemperatures = weatherAtLocation.hourly.temperature_2m.slice(
|
||||||
currentTimeIndex,
|
currentTimeIndex,
|
||||||
currentTimeIndex + hoursToShow,
|
currentTimeIndex + hoursToShow
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
"flex flex-col gap-4 rounded-2xl p-4 skeleton-bg",
|
'flex flex-col gap-4 rounded-2xl p-4 skeleton-bg max-w-[500px]',
|
||||||
{
|
{
|
||||||
"bg-blue-400": isDay,
|
'bg-blue-400': isDay,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"bg-indigo-900": !isDay,
|
'bg-indigo-900': !isDay,
|
||||||
},
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row justify-between items-center">
|
<div className="flex flex-row justify-between items-center">
|
||||||
<div className="flex flex-row gap-2 items-center">
|
<div className="flex flex-row gap-2 items-center">
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
"size-10 rounded-full skeleton-div",
|
'size-10 rounded-full skeleton-div',
|
||||||
{
|
{
|
||||||
"bg-yellow-300": isDay,
|
'bg-yellow-300': isDay,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"bg-indigo-100": !isDay,
|
'bg-indigo-100': !isDay,
|
||||||
},
|
}
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="text-4xl font-medium text-blue-50">
|
<div className="text-4xl font-medium text-blue-50">
|
||||||
|
|
@ -286,17 +286,17 @@ export function Weather({
|
||||||
{displayTimes.map((time, index) => (
|
{displayTimes.map((time, index) => (
|
||||||
<div key={time} className="flex flex-col items-center gap-1">
|
<div key={time} className="flex flex-col items-center gap-1">
|
||||||
<div className="text-blue-100 text-xs">
|
<div className="text-blue-100 text-xs">
|
||||||
{format(new Date(time), "ha")}
|
{format(new Date(time), 'ha')}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
"size-6 rounded-full skeleton-div",
|
'size-6 rounded-full skeleton-div',
|
||||||
{
|
{
|
||||||
"bg-yellow-300": isDay,
|
'bg-yellow-300': isDay,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"bg-indigo-200": !isDay,
|
'bg-indigo-200': !isDay,
|
||||||
},
|
}
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="text-blue-50 text-sm">
|
<div className="text-blue-50 text-sm">
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,56 @@
|
||||||
import * as React from "react"
|
import * as React from 'react';
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
'inline-flex items-center gap-2 justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
outline:
|
outline:
|
||||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-10 px-4 py-2",
|
default: 'h-10 px-4 py-2',
|
||||||
sm: "h-9 rounded-md px-3",
|
sm: 'h-9 rounded-md px-3',
|
||||||
lg: "h-11 rounded-md px-8",
|
lg: 'h-11 rounded-md px-8',
|
||||||
icon: "h-10 w-10",
|
icon: 'h-10 w-10',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: 'default',
|
||||||
size: "default",
|
size: 'default',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
VariantProps<typeof buttonVariants> {
|
VariantProps<typeof buttonVariants> {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : 'button';
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
Button.displayName = "Button"
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants };
|
||||||
|
|
|
||||||
79
components/ui/card.tsx
Normal file
79
components/ui/card.tsx
Normal 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 }
|
||||||
|
|
@ -1,34 +1,34 @@
|
||||||
"use client"
|
'use client';
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from 'react';
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
|
||||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
|
||||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||||
|
|
||||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||||
|
|
||||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
|
|
||||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
const DropdownMenuSubTrigger = React.forwardRef<
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}
|
}
|
||||||
>(({ className, inset, children, ...props }, ref) => (
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
|
||||||
inset && "pl-8",
|
inset && 'pl-8',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -36,9 +36,9 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
{children}
|
{children}
|
||||||
<ChevronRight className="ml-auto h-4 w-4" />
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
))
|
));
|
||||||
DropdownMenuSubTrigger.displayName =
|
DropdownMenuSubTrigger.displayName =
|
||||||
DropdownMenuPrimitive.SubTrigger.displayName
|
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
const DropdownMenuSubContent = React.forwardRef<
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
|
@ -47,14 +47,14 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DropdownMenuSubContent.displayName =
|
DropdownMenuSubContent.displayName =
|
||||||
DropdownMenuPrimitive.SubContent.displayName
|
DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
const DropdownMenuContent = React.forwardRef<
|
const DropdownMenuContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
|
@ -65,32 +65,32 @@ const DropdownMenuContent = React.forwardRef<
|
||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
'z-50 min-w-[8rem] overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuPrimitive.Portal>
|
</DropdownMenuPrimitive.Portal>
|
||||||
))
|
));
|
||||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
const DropdownMenuItem = React.forwardRef<
|
const DropdownMenuItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}
|
}
|
||||||
>(({ className, inset, ...props }, ref) => (
|
>(({ className, inset, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
'relative flex gap-2 cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
inset && "pl-8",
|
inset && 'pl-8',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
|
@ -99,7 +99,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
|
|
@ -112,9 +112,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
))
|
));
|
||||||
DropdownMenuCheckboxItem.displayName =
|
DropdownMenuCheckboxItem.displayName =
|
||||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
const DropdownMenuRadioItem = React.forwardRef<
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
|
@ -123,7 +123,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -135,26 +135,26 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
))
|
));
|
||||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
const DropdownMenuLabel = React.forwardRef<
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}
|
}
|
||||||
>(({ className, inset, ...props }, ref) => (
|
>(({ className, inset, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Label
|
<DropdownMenuPrimitive.Label
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2 py-1.5 text-sm font-semibold",
|
'px-2 py-1.5 text-sm font-semibold',
|
||||||
inset && "pl-8",
|
inset && 'pl-8',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
const DropdownMenuSeparator = React.forwardRef<
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
|
@ -162,11 +162,11 @@ const DropdownMenuSeparator = React.forwardRef<
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Separator
|
<DropdownMenuPrimitive.Separator
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
const DropdownMenuShortcut = ({
|
const DropdownMenuShortcut = ({
|
||||||
className,
|
className,
|
||||||
|
|
@ -174,12 +174,12 @@ const DropdownMenuShortcut = ({
|
||||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|
@ -197,4 +197,4 @@ export {
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuRadioGroup,
|
DropdownMenuRadioGroup,
|
||||||
}
|
};
|
||||||
|
|
|
||||||
160
components/ui/select.tsx
Normal file
160
components/ui/select.tsx
Normal 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,
|
||||||
|
}
|
||||||
31
components/ui/separator.tsx
Normal file
31
components/ui/separator.tsx
Normal 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
773
components/ui/sidebar.tsx
Normal 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,
|
||||||
|
};
|
||||||
15
components/ui/skeleton.tsx
Normal file
15
components/ui/skeleton.tsx
Normal 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
49
components/ui/tooltip.tsx
Normal 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
19
hooks/use-mobile.tsx
Normal 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
17
lib/model.ts
Normal 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];
|
||||||
|
|
@ -3,7 +3,11 @@ import type { NextConfig } from 'next';
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [],
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
hostname: 'avatar.vercel.sh',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@radix-ui/react-tooltip": "^1.1.2",
|
"@radix-ui/react-tooltip": "^1.1.2",
|
||||||
"@radix-ui/react-visually-hidden": "^1.1.0",
|
"@radix-ui/react-visually-hidden": "^1.1.0",
|
||||||
|
|
|
||||||
98
pnpm-lock.yaml
generated
98
pnpm-lock.yaml
generated
|
|
@ -26,6 +26,12 @@ importers:
|
||||||
'@radix-ui/react-label':
|
'@radix-ui/react-label':
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
|
version: 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
'@radix-ui/react-select':
|
||||||
|
specifier: ^2.1.2
|
||||||
|
version: 2.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
'@radix-ui/react-separator':
|
||||||
|
specifier: ^1.1.0
|
||||||
|
version: 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
|
||||||
'@radix-ui/react-slot':
|
'@radix-ui/react-slot':
|
||||||
specifier: ^1.1.0
|
specifier: ^1.1.0
|
||||||
version: 1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
|
version: 1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
|
@ -299,8 +305,8 @@ packages:
|
||||||
'@drizzle-team/brocli@0.10.1':
|
'@drizzle-team/brocli@0.10.1':
|
||||||
resolution: {integrity: sha512-AHy0vjc+n/4w/8Mif+w86qpppHuF3AyXbcWW+R/W7GNA3F5/p2nuhlkCJaTXSLZheB4l1rtHzOfr9A7NwoR/Zg==}
|
resolution: {integrity: sha512-AHy0vjc+n/4w/8Mif+w86qpppHuF3AyXbcWW+R/W7GNA3F5/p2nuhlkCJaTXSLZheB4l1rtHzOfr9A7NwoR/Zg==}
|
||||||
|
|
||||||
'@emnapi/runtime@1.2.0':
|
'@emnapi/runtime@1.3.1':
|
||||||
resolution: {integrity: sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==}
|
resolution: {integrity: sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==}
|
||||||
|
|
||||||
'@esbuild-kit/core-utils@3.3.2':
|
'@esbuild-kit/core-utils@3.3.2':
|
||||||
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
|
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
|
||||||
|
|
@ -985,6 +991,9 @@ packages:
|
||||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
'@radix-ui/number@1.1.0':
|
||||||
|
resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==}
|
||||||
|
|
||||||
'@radix-ui/primitive@1.1.0':
|
'@radix-ui/primitive@1.1.0':
|
||||||
resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==}
|
resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==}
|
||||||
|
|
||||||
|
|
@ -1277,6 +1286,32 @@ packages:
|
||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-select@2.1.2':
|
||||||
|
resolution: {integrity: sha512-rZJtWmorC7dFRi0owDmoijm6nSJH1tVw64QGiNIZ9PNLyBDtG+iAq+XGsya052At4BfarzY/Dhv9wrrUr6IMZA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-separator@1.1.0':
|
||||||
|
resolution: {integrity: sha512-3uBAs+egzvJBDZAzvb/n4NxxOYpnspmWxO2u5NbZ8Y6FM/NdrGSF9bop3Cf6F6C71z1rTSn8KV0Fo2ZVd79lGA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-slot@1.1.0':
|
'@radix-ui/react-slot@1.1.0':
|
||||||
resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==}
|
resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -1335,6 +1370,15 @@ packages:
|
||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-use-previous@1.1.0':
|
||||||
|
resolution: {integrity: sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-use-rect@1.1.0':
|
'@radix-ui/react-use-rect@1.1.0':
|
||||||
resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==}
|
resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -3794,7 +3838,7 @@ snapshots:
|
||||||
|
|
||||||
'@drizzle-team/brocli@0.10.1': {}
|
'@drizzle-team/brocli@0.10.1': {}
|
||||||
|
|
||||||
'@emnapi/runtime@1.2.0':
|
'@emnapi/runtime@1.3.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.6.3
|
tslib: 2.6.3
|
||||||
optional: true
|
optional: true
|
||||||
|
|
@ -4136,7 +4180,7 @@ snapshots:
|
||||||
|
|
||||||
'@img/sharp-wasm32@0.33.5':
|
'@img/sharp-wasm32@0.33.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/runtime': 1.2.0
|
'@emnapi/runtime': 1.3.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/sharp-win32-ia32@0.33.5':
|
'@img/sharp-win32-ia32@0.33.5':
|
||||||
|
|
@ -4226,6 +4270,8 @@ snapshots:
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/number@1.1.0': {}
|
||||||
|
|
||||||
'@radix-ui/primitive@1.1.0': {}
|
'@radix-ui/primitive@1.1.0': {}
|
||||||
|
|
||||||
'@radix-ui/react-alert-dialog@1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)':
|
'@radix-ui/react-alert-dialog@1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)':
|
||||||
|
|
@ -4503,6 +4549,44 @@ snapshots:
|
||||||
'@types/react': 18.3.3
|
'@types/react': 18.3.3
|
||||||
'@types/react-dom': 18.3.0
|
'@types/react-dom': 18.3.0
|
||||||
|
|
||||||
|
'@radix-ui/react-select@2.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/number': 1.1.0
|
||||||
|
'@radix-ui/primitive': 1.1.0
|
||||||
|
'@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
'@radix-ui/react-context': 1.1.1(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
'@radix-ui/react-direction': 1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
'@radix-ui/react-dismissable-layer': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
'@radix-ui/react-focus-guards': 1.1.1(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
'@radix-ui/react-focus-scope': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
'@radix-ui/react-id': 1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
'@radix-ui/react-popper': 1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
'@radix-ui/react-portal': 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
'@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
'@radix-ui/react-slot': 1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
'@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
'@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
'@radix-ui/react-visually-hidden': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
aria-hidden: 1.2.4
|
||||||
|
react: 19.0.0-rc-45804af1-20241021
|
||||||
|
react-dom: 19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
react-remove-scroll: 2.6.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 18.3.3
|
||||||
|
'@types/react-dom': 18.3.0
|
||||||
|
|
||||||
|
'@radix-ui/react-separator@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
react: 19.0.0-rc-45804af1-20241021
|
||||||
|
react-dom: 19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 18.3.3
|
||||||
|
'@types/react-dom': 18.3.0
|
||||||
|
|
||||||
'@radix-ui/react-slot@1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)':
|
'@radix-ui/react-slot@1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
|
'@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
|
@ -4556,6 +4640,12 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 18.3.3
|
'@types/react': 18.3.3
|
||||||
|
|
||||||
|
'@radix-ui/react-use-previous@1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.0.0-rc-45804af1-20241021
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 18.3.3
|
||||||
|
|
||||||
'@radix-ui/react-use-rect@1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)':
|
'@radix-ui/react-use-rect@1.1.0(@types/react@18.3.3)(react@19.0.0-rc-45804af1-20241021)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/rect': 1.1.0
|
'@radix-ui/rect': 1.1.0
|
||||||
|
|
|
||||||
9
prettier.config.cjs
Normal file
9
prettier.config.cjs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
/** @type {import('prettier').Config} */
|
||||||
|
module.exports = {
|
||||||
|
endOfLine: 'lf',
|
||||||
|
semi: true,
|
||||||
|
useTabs: false,
|
||||||
|
singleQuote: true,
|
||||||
|
tabWidth: 2,
|
||||||
|
trailingComma: 'es5',
|
||||||
|
};
|
||||||
|
|
@ -1,68 +1,78 @@
|
||||||
import type { Config } from "tailwindcss";
|
import type { Config } from 'tailwindcss';
|
||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
darkMode: ["class"],
|
darkMode: ['class'],
|
||||||
content: [
|
content: [
|
||||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ["geist"],
|
sans: ['geist'],
|
||||||
mono: ["geist-mono"],
|
mono: ['geist-mono'],
|
||||||
},
|
},
|
||||||
extend: {
|
extend: {
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
lg: 'var(--radius)',
|
||||||
md: "calc(var(--radius) - 2px)",
|
md: 'calc(var(--radius) - 2px)',
|
||||||
sm: "calc(var(--radius) - 4px)",
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
background: "hsl(var(--background))",
|
background: 'hsl(var(--background))',
|
||||||
foreground: "hsl(var(--foreground))",
|
foreground: 'hsl(var(--foreground))',
|
||||||
card: {
|
card: {
|
||||||
DEFAULT: "hsl(var(--card))",
|
DEFAULT: 'hsl(var(--card))',
|
||||||
foreground: "hsl(var(--card-foreground))",
|
foreground: 'hsl(var(--card-foreground))',
|
||||||
},
|
},
|
||||||
popover: {
|
popover: {
|
||||||
DEFAULT: "hsl(var(--popover))",
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
foreground: "hsl(var(--popover-foreground))",
|
foreground: 'hsl(var(--popover-foreground))',
|
||||||
},
|
},
|
||||||
primary: {
|
primary: {
|
||||||
DEFAULT: "hsl(var(--primary))",
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
foreground: "hsl(var(--primary-foreground))",
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
DEFAULT: "hsl(var(--secondary))",
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
foreground: "hsl(var(--secondary-foreground))",
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
},
|
},
|
||||||
muted: {
|
muted: {
|
||||||
DEFAULT: "hsl(var(--muted))",
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
foreground: "hsl(var(--muted-foreground))",
|
foreground: 'hsl(var(--muted-foreground))',
|
||||||
},
|
},
|
||||||
accent: {
|
accent: {
|
||||||
DEFAULT: "hsl(var(--accent))",
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
foreground: "hsl(var(--accent-foreground))",
|
foreground: 'hsl(var(--accent-foreground))',
|
||||||
},
|
},
|
||||||
destructive: {
|
destructive: {
|
||||||
DEFAULT: "hsl(var(--destructive))",
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
foreground: "hsl(var(--destructive-foreground))",
|
foreground: 'hsl(var(--destructive-foreground))',
|
||||||
},
|
},
|
||||||
border: "hsl(var(--border))",
|
border: 'hsl(var(--border))',
|
||||||
input: "hsl(var(--input))",
|
input: 'hsl(var(--input))',
|
||||||
ring: "hsl(var(--ring))",
|
ring: 'hsl(var(--ring))',
|
||||||
chart: {
|
chart: {
|
||||||
"1": "hsl(var(--chart-1))",
|
'1': 'hsl(var(--chart-1))',
|
||||||
"2": "hsl(var(--chart-2))",
|
'2': 'hsl(var(--chart-2))',
|
||||||
"3": "hsl(var(--chart-3))",
|
'3': 'hsl(var(--chart-3))',
|
||||||
"4": "hsl(var(--chart-4))",
|
'4': 'hsl(var(--chart-4))',
|
||||||
"5": "hsl(var(--chart-5))",
|
'5': 'hsl(var(--chart-5))',
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
DEFAULT: 'hsl(var(--sidebar-background))',
|
||||||
|
foreground: 'hsl(var(--sidebar-foreground))',
|
||||||
|
primary: 'hsl(var(--sidebar-primary))',
|
||||||
|
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
|
||||||
|
accent: 'hsl(var(--sidebar-accent))',
|
||||||
|
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
|
||||||
|
border: 'hsl(var(--sidebar-border))',
|
||||||
|
ring: 'hsl(var(--sidebar-ring))',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require("tailwindcss-animate")],
|
plugins: [require('tailwindcss-animate')],
|
||||||
safelist: ["w-32", "w-44", "w-52"],
|
safelist: ['w-32', 'w-44', 'w-52'],
|
||||||
};
|
};
|
||||||
export default config;
|
export default config;
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,12 @@
|
||||||
"@/*": ["./*"]
|
"@/*": ["./*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
"next.config.js"
|
||||||
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue