mirror of
https://litchi.icu/ngc2207/judge.git
synced 2025-05-18 19:56:33 +00:00
feat(playground): add theme provider and toggle, implement chat input and loading components
This commit is contained in:
parent
265f49a669
commit
41eca2305c
@ -9,6 +9,7 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/openai": "^1.0.13",
|
||||||
"@auth/prisma-adapter": "^2.7.4",
|
"@auth/prisma-adapter": "^2.7.4",
|
||||||
"@monaco-editor/react": "^4.6.0",
|
"@monaco-editor/react": "^4.6.0",
|
||||||
"@prisma/client": "^6.1.0",
|
"@prisma/client": "^6.1.0",
|
||||||
@ -19,6 +20,8 @@
|
|||||||
"@radix-ui/react-slider": "^1.2.2",
|
"@radix-ui/react-slider": "^1.2.2",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.1",
|
||||||
|
"ai": "^4.0.27",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"devicons-react": "^1.4.0",
|
"devicons-react": "^1.4.0",
|
||||||
@ -29,9 +32,11 @@
|
|||||||
"monaco-languageclient": "5.0.1",
|
"monaco-languageclient": "5.0.1",
|
||||||
"next": "15.1.3",
|
"next": "15.1.3",
|
||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
|
"next-themes": "^0.4.4",
|
||||||
"normalize-url": "~8.0.0",
|
"normalize-url": "~8.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-resizable-panels": "^2.1.7",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vscode-languageclient": "~8.1.0",
|
"vscode-languageclient": "~8.1.0",
|
||||||
|
6
public/ai.svg
Normal file
6
public/ai.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fa7185"
|
||||||
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ghost">
|
||||||
|
<path d="M9 10h.01" />
|
||||||
|
<path d="M15 10h.01" />
|
||||||
|
<path d="M12 2a8 8 0 0 0-8 8v12l3-3 2.5 2.5L12 19l2.5 2.5L17 19l3 3V10a8 8 0 0 0-8-8z" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 364 B |
@ -1,9 +1,6 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#0fb880"
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#37bdf7"
|
||||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bird">
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ghost">
|
||||||
<path d="M16 7h.01" />
|
<path d="M9 10h.01" />
|
||||||
<path d="M3.4 18H12a8 8 0 0 0 8-8V7a4 4 0 0 0-7.28-2.3L2 20" />
|
<path d="M15 10h.01" />
|
||||||
<path d="m20 7 2 .5-2 .5" />
|
<path d="M12 2a8 8 0 0 0-8 8v12l3-3 2.5 2.5L12 19l2.5 2.5L17 19l3 3V10a8 8 0 0 0-8-8z" />
|
||||||
<path d="M10 18v3" />
|
|
||||||
<path d="M14 17.75V21" />
|
|
||||||
<path d="M7 18a6 6 0 0 0 3.84-10.61" />
|
|
||||||
</svg>
|
</svg>
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 364 B |
20
src/app/api/chat/route.ts
Normal file
20
src/app/api/chat/route.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { streamText } from "ai";
|
||||||
|
import { createOpenAI } from "@ai-sdk/openai";
|
||||||
|
|
||||||
|
export const maxDuration = 30;
|
||||||
|
|
||||||
|
const openai = createOpenAI({
|
||||||
|
apiKey: process.env.OPENAI_API_KEY || "",
|
||||||
|
baseURL: process.env.OPENAI_BASE_URL || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { messages } = await req.json();
|
||||||
|
|
||||||
|
const result = streamText({
|
||||||
|
model: openai("deepseek-chat"),
|
||||||
|
messages,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.toDataStreamResponse();
|
||||||
|
}
|
@ -2,6 +2,7 @@ import "@/app/globals.css";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
||||||
|
|
||||||
@ -21,7 +22,16 @@ export default function RootLayout({
|
|||||||
<body
|
<body
|
||||||
className={cn("font-sans antialiased flex min-h-full", inter.variable)}
|
className={cn("font-sans antialiased flex min-h-full", inter.variable)}
|
||||||
>
|
>
|
||||||
<main className="w-full">{children}</main>
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="dark"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
<main className="w-full">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
20
src/app/playground/components/banner.tsx
Normal file
20
src/app/playground/components/banner.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
export default function Banner() {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 bg-zinc-800">
|
||||||
|
<p className="flex justify-center text-sm">
|
||||||
|
<a href="https://github.com/NGC2207/judge4c" className="group">
|
||||||
|
<span className="me-1 text-base leading-none">✨</span>
|
||||||
|
Introducing to Judge4c
|
||||||
|
<ArrowRight
|
||||||
|
className="-mt-0.5 ms-2 inline-flex opacity-60 transition-transform group-hover:translate-x-0.5"
|
||||||
|
size={16}
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
21
src/app/playground/components/button/run.tsx
Normal file
21
src/app/playground/components/button/run.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Play } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function Run() {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex -space-x-px rounded-lg shadow-sm shadow-black/5 rtl:space-x-reverse">
|
||||||
|
<Button
|
||||||
|
className="rounded-none shadow-none first:rounded-s-lg last:rounded-e-lg focus-visible:z-10"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Play
|
||||||
|
className="-ms-1 me-2 opacity-60"
|
||||||
|
size={16}
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
Run
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
57
src/app/playground/components/chat.tsx
Normal file
57
src/app/playground/components/chat.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useChat } from "ai/react";
|
||||||
|
import { Send } from "lucide-react";
|
||||||
|
import {
|
||||||
|
ChatBubble,
|
||||||
|
ChatBubbleAvatar,
|
||||||
|
ChatBubbleMessage,
|
||||||
|
} from "@/components/ui/chat/chat-bubble";
|
||||||
|
import {
|
||||||
|
ExpandableChatHeader,
|
||||||
|
ExpandableChatBody,
|
||||||
|
ExpandableChatFooter,
|
||||||
|
} from "@/components/ui/chat/expandable-chat";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ChatInput } from "@/components/ui/chat/chat-input";
|
||||||
|
import { ChatMessageList } from "@/components/ui/chat/chat-message-list";
|
||||||
|
|
||||||
|
export default function Chat() {
|
||||||
|
const { messages, input, handleInputChange, handleSubmit } = useChat();
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full flex flex-col border sm:rounded-lg shadow-md overflow-hidden transition-all duration-200 ease-out">
|
||||||
|
<ExpandableChatHeader className="flex-col text-center justify-center border-[#3e4452]">
|
||||||
|
<h1 className="text-xl font-semibold">Chat with AI Assistant ✨</h1>
|
||||||
|
</ExpandableChatHeader>
|
||||||
|
<ExpandableChatBody>
|
||||||
|
<ChatMessageList>
|
||||||
|
{messages.map((message, index) => (
|
||||||
|
<ChatBubble key={index} layout="ai">
|
||||||
|
<ChatBubbleAvatar
|
||||||
|
src={message.role === "user" ? "/default.svg" : "/ai.svg"}
|
||||||
|
fallback={message.role === "user" ? "US" : "AI"}
|
||||||
|
/>
|
||||||
|
<ChatBubbleMessage layout="ai" className="border-[#3e4452]">
|
||||||
|
{message.content}
|
||||||
|
</ChatBubbleMessage>
|
||||||
|
</ChatBubble>
|
||||||
|
))}
|
||||||
|
</ChatMessageList>
|
||||||
|
</ExpandableChatBody>
|
||||||
|
<ExpandableChatFooter className="border-[#3e4452]">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<ChatInput
|
||||||
|
value={input}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="bg-[#3e4452]"
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="icon">
|
||||||
|
<Send className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ExpandableChatFooter>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
28
src/app/playground/components/nav.tsx
Normal file
28
src/app/playground/components/nav.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from "@/components/ui/breadcrumb";
|
||||||
|
import { Home } from "lucide-react";
|
||||||
|
|
||||||
|
export default function Nav() {
|
||||||
|
return (
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList className="rounded-lg border border-border bg-background px-3 py-2 shadow-sm shadow-black/5">
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink href="#">
|
||||||
|
<Home size={16} strokeWidth={2} aria-hidden="true" />
|
||||||
|
<span className="sr-only">Home</span>
|
||||||
|
</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbPage>Playground</BreadcrumbPage>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
);
|
||||||
|
}
|
68
src/app/playground/components/terminal.tsx
Normal file
68
src/app/playground/components/terminal.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { Box, House, PanelsTopLeft } from "lucide-react";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
|
export default function Terminal() {
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
defaultValue="tab-1"
|
||||||
|
orientation="vertical"
|
||||||
|
className="flex h-full w-full gap-2 py-3 pr-3"
|
||||||
|
>
|
||||||
|
<TabsList className="justify-start flex-col gap-1 rounded-none bg-transparent px-1 py-0 text-foreground">
|
||||||
|
<TabsTrigger
|
||||||
|
value="tab-1"
|
||||||
|
className="relative w-full justify-start after:absolute after:inset-y-0 after:start-0 after:-ms-1 after:w-0.5 hover:bg-accent hover:text-foreground data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:after:bg-primary data-[state=active]:hover:bg-accent"
|
||||||
|
>
|
||||||
|
<House
|
||||||
|
className="-ms-0.5 me-1.5 opacity-60"
|
||||||
|
size={16}
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
Overview
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="tab-2"
|
||||||
|
className="relative w-full justify-start after:absolute after:inset-y-0 after:start-0 after:-ms-1 after:w-0.5 hover:bg-accent hover:text-foreground data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:after:bg-primary data-[state=active]:hover:bg-accent"
|
||||||
|
>
|
||||||
|
<PanelsTopLeft
|
||||||
|
className="-ms-0.5 me-1.5 opacity-60"
|
||||||
|
size={16}
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
Projects
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="tab-3"
|
||||||
|
className="relative w-full justify-start after:absolute after:inset-y-0 after:start-0 after:-ms-1 after:w-0.5 hover:bg-accent hover:text-foreground data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:after:bg-primary data-[state=active]:hover:bg-accent"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
className="-ms-0.5 me-1.5 opacity-60"
|
||||||
|
size={16}
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
Packages
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<div className="grow rounded-lg border border-border text-start bg-[#3e4452]">
|
||||||
|
<TabsContent value="tab-1">
|
||||||
|
<p className="px-4 py-1.5 text-xs text-muted-foreground">
|
||||||
|
Content for Tab 1
|
||||||
|
</p>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="tab-2">
|
||||||
|
<p className="px-4 py-1.5 text-xs text-muted-foreground">
|
||||||
|
Content for Tab 2
|
||||||
|
</p>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="tab-3">
|
||||||
|
<p className="px-4 py-1.5 text-xs text-muted-foreground">
|
||||||
|
Content for Tab 3
|
||||||
|
</p>
|
||||||
|
</TabsContent>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
23
src/app/playground/components/tools.tsx
Normal file
23
src/app/playground/components/tools.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import Run from "./button/run";
|
||||||
|
import Nav from "./nav";
|
||||||
|
import User from "./user";
|
||||||
|
|
||||||
|
export default function Tools() {
|
||||||
|
return (
|
||||||
|
<header className="relative bg-neutral-700 h-12">
|
||||||
|
<nav className="flex items-center h-full px-5">
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Nav />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex items-center gap-2">
|
||||||
|
<User />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div className="absolute left-1/2 top-0 h-full flex items-center -translate-x-1/2">
|
||||||
|
<Run />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
35
src/app/playground/components/user.tsx
Normal file
35
src/app/playground/components/user.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
|
||||||
|
export default function User() {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage src="/default.svg" alt="User Avatar" />
|
||||||
|
<AvatarFallback>KK</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="absolute -end-1 -top-1">
|
||||||
|
<span className="sr-only">Verified</span>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
className="fill-background"
|
||||||
|
d="M3.046 8.277A4.402 4.402 0 0 1 8.303 3.03a4.4 4.4 0 0 1 7.411 0 4.397 4.397 0 0 1 5.19 3.068c.207.713.23 1.466.067 2.19a4.4 4.4 0 0 1 0 7.415 4.403 4.403 0 0 1-3.06 5.187 4.398 4.398 0 0 1-2.186.072 4.398 4.398 0 0 1-7.422 0 4.398 4.398 0 0 1-5.257-5.248 4.4 4.4 0 0 1 0-7.437Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="fill-primary"
|
||||||
|
d="M4.674 8.954a3.602 3.602 0 0 1 4.301-4.293 3.6 3.6 0 0 1 6.064 0 3.598 3.598 0 0 1 4.3 4.302 3.6 3.6 0 0 1 0 6.067 3.6 3.6 0 0 1-4.29 4.302 3.6 3.6 0 0 1-6.074 0 3.598 3.598 0 0 1-4.3-4.293 3.6 3.6 0 0 1 0-6.085Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="fill-background"
|
||||||
|
d="M15.707 9.293a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 1 1 1.414-1.414L11 12.586l3.293-3.293a1 1 0 0 1 1.414 0Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
41
src/app/playground/layout.tsx
Normal file
41
src/app/playground/layout.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import Tools from "./components/tools";
|
||||||
|
import Banner from "./components/banner";
|
||||||
|
import Terminal from "./components/terminal";
|
||||||
|
import Chat from "./components/chat";
|
||||||
|
|
||||||
|
export default function PlaygroundLayout() {
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col bg-[#282c34]">
|
||||||
|
<Banner />
|
||||||
|
<Tools />
|
||||||
|
<div className="flex flex-1 min-h-0">
|
||||||
|
<ResizablePanelGroup direction="horizontal">
|
||||||
|
<ResizablePanel defaultSize={25}>One</ResizablePanel>
|
||||||
|
<ResizableHandle className="bg-[#3e4452]" />
|
||||||
|
<ResizablePanel defaultSize={75}>
|
||||||
|
<ResizablePanelGroup direction="vertical">
|
||||||
|
<ResizablePanel defaultSize={75}>
|
||||||
|
<ResizablePanelGroup direction="horizontal">
|
||||||
|
<ResizablePanel defaultSize={75}>One</ResizablePanel>
|
||||||
|
<ResizableHandle className="bg-[#3e4452]" />
|
||||||
|
<ResizablePanel defaultSize={25}>
|
||||||
|
<Chat/>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle className="bg-[#3e4452]" />
|
||||||
|
<ResizablePanel defaultSize={25}>
|
||||||
|
<Terminal />
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
11
src/components/theme-provider.tsx
Normal file
11
src/components/theme-provider.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||||
|
|
||||||
|
export function ThemeProvider({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
}
|
36
src/components/theme-toggle.tsx
Normal file
36
src/components/theme-toggle.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { Toggle } from "@/components/ui/toggle";
|
||||||
|
|
||||||
|
export default function ButtonDemo() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Toggle
|
||||||
|
variant="outline"
|
||||||
|
className="group size-9"
|
||||||
|
pressed={theme === "dark"}
|
||||||
|
onPressedChange={() =>
|
||||||
|
setTheme((prev) => (prev === "dark" ? "light" : "dark"))
|
||||||
|
}
|
||||||
|
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
|
||||||
|
>
|
||||||
|
<Moon
|
||||||
|
size={16}
|
||||||
|
strokeWidth={2}
|
||||||
|
className="shrink-0 scale-0 opacity-0 transition-all group-data-[state=on]:scale-100 group-data-[state=on]:opacity-100"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<Sun
|
||||||
|
size={16}
|
||||||
|
strokeWidth={2}
|
||||||
|
className="absolute shrink-0 scale-100 opacity-100 transition-all group-data-[state=on]:scale-0 group-data-[state=on]:opacity-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</Toggle>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
115
src/components/ui/breadcrumb.tsx
Normal file
115
src/components/ui/breadcrumb.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Breadcrumb = React.forwardRef<
|
||||||
|
HTMLElement,
|
||||||
|
React.ComponentPropsWithoutRef<"nav"> & {
|
||||||
|
separator?: React.ReactNode
|
||||||
|
}
|
||||||
|
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
|
||||||
|
Breadcrumb.displayName = "Breadcrumb"
|
||||||
|
|
||||||
|
const BreadcrumbList = React.forwardRef<
|
||||||
|
HTMLOListElement,
|
||||||
|
React.ComponentPropsWithoutRef<"ol">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ol
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
BreadcrumbList.displayName = "BreadcrumbList"
|
||||||
|
|
||||||
|
const BreadcrumbItem = React.forwardRef<
|
||||||
|
HTMLLIElement,
|
||||||
|
React.ComponentPropsWithoutRef<"li">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<li
|
||||||
|
ref={ref}
|
||||||
|
className={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
BreadcrumbItem.displayName = "BreadcrumbItem"
|
||||||
|
|
||||||
|
const BreadcrumbLink = React.forwardRef<
|
||||||
|
HTMLAnchorElement,
|
||||||
|
React.ComponentPropsWithoutRef<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
>(({ asChild, className, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
className={cn("transition-colors hover:text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
BreadcrumbLink.displayName = "BreadcrumbLink"
|
||||||
|
|
||||||
|
const BreadcrumbPage = React.forwardRef<
|
||||||
|
HTMLSpanElement,
|
||||||
|
React.ComponentPropsWithoutRef<"span">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("font-normal text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
BreadcrumbPage.displayName = "BreadcrumbPage"
|
||||||
|
|
||||||
|
const BreadcrumbSeparator = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) => (
|
||||||
|
<li
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
|
||||||
|
|
||||||
|
const BreadcrumbEllipsis = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) => (
|
||||||
|
<span
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
202
src/components/ui/chat/chat-bubble.tsx
Normal file
202
src/components/ui/chat/chat-bubble.tsx
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import MessageLoading from "./message-loading";
|
||||||
|
import { Button, ButtonProps } from "../button";
|
||||||
|
|
||||||
|
// ChatBubble
|
||||||
|
const chatBubbleVariant = cva(
|
||||||
|
"flex gap-2 max-w-[60%] items-end relative group",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
received: "self-start",
|
||||||
|
sent: "self-end flex-row-reverse",
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
default: "",
|
||||||
|
ai: "max-w-full w-full items-center",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "received",
|
||||||
|
layout: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ChatBubbleProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof chatBubbleVariant> {}
|
||||||
|
|
||||||
|
const ChatBubble = React.forwardRef<HTMLDivElement, ChatBubbleProps>(
|
||||||
|
({ className, variant, layout, children, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
chatBubbleVariant({ variant, layout, className }),
|
||||||
|
"relative group",
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{React.Children.map(children, (child) =>
|
||||||
|
React.isValidElement(child) && typeof child.type !== "string"
|
||||||
|
? React.cloneElement(child, {
|
||||||
|
variant,
|
||||||
|
layout,
|
||||||
|
} as React.ComponentProps<typeof child.type>)
|
||||||
|
: child,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ChatBubble.displayName = "ChatBubble";
|
||||||
|
|
||||||
|
// ChatBubbleAvatar
|
||||||
|
interface ChatBubbleAvatarProps {
|
||||||
|
src?: string;
|
||||||
|
fallback?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatBubbleAvatar: React.FC<ChatBubbleAvatarProps> = ({
|
||||||
|
src,
|
||||||
|
fallback,
|
||||||
|
className,
|
||||||
|
}) => (
|
||||||
|
<Avatar className={className}>
|
||||||
|
<AvatarImage src={src} alt="Avatar" />
|
||||||
|
<AvatarFallback>{fallback}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ChatBubbleMessage
|
||||||
|
const chatBubbleMessageVariants = cva("p-4", {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
received:
|
||||||
|
"bg-secondary text-secondary-foreground rounded-r-lg rounded-tl-lg",
|
||||||
|
sent: "bg-primary text-primary-foreground rounded-l-lg rounded-tr-lg",
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
default: "",
|
||||||
|
ai: "border-t w-full rounded-none bg-transparent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "received",
|
||||||
|
layout: "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ChatBubbleMessageProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof chatBubbleMessageVariants> {
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatBubbleMessage = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
ChatBubbleMessageProps
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, variant, layout, isLoading = false, children, ...props },
|
||||||
|
ref,
|
||||||
|
) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
chatBubbleMessageVariants({ variant, layout, className }),
|
||||||
|
"break-words max-w-full whitespace-pre-wrap",
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<MessageLoading />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ChatBubbleMessage.displayName = "ChatBubbleMessage";
|
||||||
|
|
||||||
|
// ChatBubbleTimestamp
|
||||||
|
interface ChatBubbleTimestampProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatBubbleTimestamp: React.FC<ChatBubbleTimestampProps> = ({
|
||||||
|
timestamp,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => (
|
||||||
|
<div className={cn("text-xs mt-2 text-right", className)} {...props}>
|
||||||
|
{timestamp}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ChatBubbleAction
|
||||||
|
type ChatBubbleActionProps = ButtonProps & {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChatBubbleAction: React.FC<ChatBubbleActionProps> = ({
|
||||||
|
icon,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
variant = "ghost",
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}) => (
|
||||||
|
<Button
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={className}
|
||||||
|
onClick={onClick}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ChatBubbleActionWrapperProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
variant?: "sent" | "received";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatBubbleActionWrapper = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
ChatBubbleActionWrapperProps
|
||||||
|
>(({ variant, className, children, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"absolute top-1/2 -translate-y-1/2 flex opacity-0 group-hover:opacity-100 transition-opacity duration-200",
|
||||||
|
variant === "sent"
|
||||||
|
? "-left-1 -translate-x-full flex-row-reverse"
|
||||||
|
: "-right-1 translate-x-full",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
ChatBubbleActionWrapper.displayName = "ChatBubbleActionWrapper";
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChatBubble,
|
||||||
|
ChatBubbleAvatar,
|
||||||
|
ChatBubbleMessage,
|
||||||
|
ChatBubbleTimestamp,
|
||||||
|
chatBubbleVariant,
|
||||||
|
chatBubbleMessageVariants,
|
||||||
|
ChatBubbleAction,
|
||||||
|
ChatBubbleActionWrapper,
|
||||||
|
};
|
23
src/components/ui/chat/chat-input.tsx
Normal file
23
src/components/ui/chat/chat-input.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ChatInputProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement>{}
|
||||||
|
|
||||||
|
const ChatInput = React.forwardRef<HTMLTextAreaElement, ChatInputProps>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<Textarea
|
||||||
|
autoComplete="off"
|
||||||
|
ref={ref}
|
||||||
|
name="message"
|
||||||
|
className={cn(
|
||||||
|
"max-h-12 px-4 py-3 bg-background text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 w-full rounded-md flex items-center h-16 resize-none",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ChatInput.displayName = "ChatInput";
|
||||||
|
|
||||||
|
export { ChatInput };
|
55
src/components/ui/chat/chat-message-list.tsx
Normal file
55
src/components/ui/chat/chat-message-list.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { ArrowDown } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useAutoScroll } from "@/components/ui/chat/hooks/useAutoScroll";
|
||||||
|
|
||||||
|
interface ChatMessageListProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
smooth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatMessageList = React.forwardRef<HTMLDivElement, ChatMessageListProps>(
|
||||||
|
({ className, children, smooth = false, ...props }, _ref) => {
|
||||||
|
const {
|
||||||
|
scrollRef,
|
||||||
|
isAtBottom,
|
||||||
|
autoScrollEnabled,
|
||||||
|
scrollToBottom,
|
||||||
|
disableAutoScroll,
|
||||||
|
} = useAutoScroll({
|
||||||
|
smooth,
|
||||||
|
content: children,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full">
|
||||||
|
<div
|
||||||
|
className={`flex flex-col w-full h-full p-4 overflow-y-auto ${className}`}
|
||||||
|
ref={scrollRef}
|
||||||
|
onWheel={disableAutoScroll}
|
||||||
|
onTouchMove={disableAutoScroll}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-6">{children}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isAtBottom && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}}
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="absolute bottom-2 left-1/2 transform -translate-x-1/2 inline-flex rounded-full shadow-md"
|
||||||
|
aria-label="Scroll to bottom"
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ChatMessageList.displayName = "ChatMessageList";
|
||||||
|
|
||||||
|
export { ChatMessageList };
|
153
src/components/ui/chat/expandable-chat.tsx
Normal file
153
src/components/ui/chat/expandable-chat.tsx
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import { X, MessageCircle } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export type ChatPosition = "bottom-right" | "bottom-left";
|
||||||
|
export type ChatSize = "sm" | "md" | "lg" | "xl" | "full";
|
||||||
|
|
||||||
|
const chatConfig = {
|
||||||
|
dimensions: {
|
||||||
|
sm: "sm:max-w-sm sm:max-h-[500px]",
|
||||||
|
md: "sm:max-w-md sm:max-h-[600px]",
|
||||||
|
lg: "sm:max-w-lg sm:max-h-[700px]",
|
||||||
|
xl: "sm:max-w-xl sm:max-h-[800px]",
|
||||||
|
full: "sm:w-full sm:h-full",
|
||||||
|
},
|
||||||
|
positions: {
|
||||||
|
"bottom-right": "bottom-5 right-5",
|
||||||
|
"bottom-left": "bottom-5 left-5",
|
||||||
|
},
|
||||||
|
chatPositions: {
|
||||||
|
"bottom-right": "sm:bottom-[calc(100%+10px)] sm:right-0",
|
||||||
|
"bottom-left": "sm:bottom-[calc(100%+10px)] sm:left-0",
|
||||||
|
},
|
||||||
|
states: {
|
||||||
|
open: "pointer-events-auto opacity-100 visible scale-100 translate-y-0",
|
||||||
|
closed:
|
||||||
|
"pointer-events-none opacity-0 invisible scale-100 sm:translate-y-5",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ExpandableChatProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
position?: ChatPosition;
|
||||||
|
size?: ChatSize;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExpandableChat: React.FC<ExpandableChatProps> = ({
|
||||||
|
className,
|
||||||
|
position = "bottom-right",
|
||||||
|
size = "md",
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const chatRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const toggleChat = () => setIsOpen(!isOpen);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(`fixed ${chatConfig.positions[position]} z-50`, className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={chatRef}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col bg-background border sm:rounded-lg shadow-md overflow-hidden transition-all duration-250 ease-out sm:absolute sm:w-[90vw] sm:h-[80vh] fixed inset-0 w-full h-full sm:inset-auto",
|
||||||
|
chatConfig.chatPositions[position],
|
||||||
|
chatConfig.dimensions[size],
|
||||||
|
isOpen ? chatConfig.states.open : chatConfig.states.closed,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-2 right-2 sm:hidden"
|
||||||
|
onClick={toggleChat}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ExpandableChatToggle
|
||||||
|
icon={icon}
|
||||||
|
isOpen={isOpen}
|
||||||
|
toggleChat={toggleChat}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ExpandableChat.displayName = "ExpandableChat";
|
||||||
|
|
||||||
|
const ExpandableChatHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className={cn("flex items-center justify-between p-4 border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
ExpandableChatHeader.displayName = "ExpandableChatHeader";
|
||||||
|
|
||||||
|
const ExpandableChatBody: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => <div className={cn("flex-grow overflow-y-auto", className)} {...props} />;
|
||||||
|
|
||||||
|
ExpandableChatBody.displayName = "ExpandableChatBody";
|
||||||
|
|
||||||
|
const ExpandableChatFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => <div className={cn("border-t p-4", className)} {...props} />;
|
||||||
|
|
||||||
|
ExpandableChatFooter.displayName = "ExpandableChatFooter";
|
||||||
|
|
||||||
|
interface ExpandableChatToggleProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
isOpen: boolean;
|
||||||
|
toggleChat: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExpandableChatToggle: React.FC<ExpandableChatToggleProps> = ({
|
||||||
|
className,
|
||||||
|
icon,
|
||||||
|
isOpen,
|
||||||
|
toggleChat,
|
||||||
|
...props
|
||||||
|
}) => (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={toggleChat}
|
||||||
|
className={cn(
|
||||||
|
"w-14 h-14 rounded-full shadow-md flex items-center justify-center hover:shadow-lg hover:shadow-black/30 transition-all duration-300",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isOpen ? (
|
||||||
|
<X className="h-6 w-6" />
|
||||||
|
) : (
|
||||||
|
icon || <MessageCircle className="h-6 w-6" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
ExpandableChatToggle.displayName = "ExpandableChatToggle";
|
||||||
|
|
||||||
|
export {
|
||||||
|
ExpandableChat,
|
||||||
|
ExpandableChatHeader,
|
||||||
|
ExpandableChatBody,
|
||||||
|
ExpandableChatFooter,
|
||||||
|
};
|
135
src/components/ui/chat/hooks/useAutoScroll.tsx
Normal file
135
src/components/ui/chat/hooks/useAutoScroll.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
// @hidden
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface ScrollState {
|
||||||
|
isAtBottom: boolean;
|
||||||
|
autoScrollEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAutoScrollOptions {
|
||||||
|
offset?: number;
|
||||||
|
smooth?: boolean;
|
||||||
|
content?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutoScroll(options: UseAutoScrollOptions = {}) {
|
||||||
|
const { offset = 20, smooth = false, content } = options;
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const lastContentHeight = useRef(0);
|
||||||
|
const userHasScrolled = useRef(false);
|
||||||
|
|
||||||
|
const [scrollState, setScrollState] = useState<ScrollState>({
|
||||||
|
isAtBottom: true,
|
||||||
|
autoScrollEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkIsAtBottom = useCallback(
|
||||||
|
(element: HTMLElement) => {
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = element;
|
||||||
|
const distanceToBottom = Math.abs(
|
||||||
|
scrollHeight - scrollTop - clientHeight
|
||||||
|
);
|
||||||
|
return distanceToBottom <= offset;
|
||||||
|
},
|
||||||
|
[offset]
|
||||||
|
);
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback(
|
||||||
|
(instant?: boolean) => {
|
||||||
|
if (!scrollRef.current) return;
|
||||||
|
|
||||||
|
const targetScrollTop =
|
||||||
|
scrollRef.current.scrollHeight - scrollRef.current.clientHeight;
|
||||||
|
|
||||||
|
if (instant) {
|
||||||
|
scrollRef.current.scrollTop = targetScrollTop;
|
||||||
|
} else {
|
||||||
|
scrollRef.current.scrollTo({
|
||||||
|
top: targetScrollTop,
|
||||||
|
behavior: smooth ? "smooth" : "auto",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setScrollState({
|
||||||
|
isAtBottom: true,
|
||||||
|
autoScrollEnabled: true,
|
||||||
|
});
|
||||||
|
userHasScrolled.current = false;
|
||||||
|
},
|
||||||
|
[smooth]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
if (!scrollRef.current) return;
|
||||||
|
|
||||||
|
const atBottom = checkIsAtBottom(scrollRef.current);
|
||||||
|
|
||||||
|
setScrollState((prev) => ({
|
||||||
|
isAtBottom: atBottom,
|
||||||
|
// Re-enable auto-scroll if at the bottom
|
||||||
|
autoScrollEnabled: atBottom ? true : prev.autoScrollEnabled,
|
||||||
|
}));
|
||||||
|
}, [checkIsAtBottom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = scrollRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
element.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
return () => element.removeEventListener("scroll", handleScroll);
|
||||||
|
}, [handleScroll]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scrollElement = scrollRef.current;
|
||||||
|
if (!scrollElement) return;
|
||||||
|
|
||||||
|
const currentHeight = scrollElement.scrollHeight;
|
||||||
|
const hasNewContent = currentHeight !== lastContentHeight.current;
|
||||||
|
|
||||||
|
if (hasNewContent) {
|
||||||
|
if (scrollState.autoScrollEnabled) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
scrollToBottom(lastContentHeight.current === 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
lastContentHeight.current = currentHeight;
|
||||||
|
}
|
||||||
|
}, [content, scrollState.autoScrollEnabled, scrollToBottom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = scrollRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (scrollState.autoScrollEnabled) {
|
||||||
|
scrollToBottom(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(element);
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}, [scrollState.autoScrollEnabled, scrollToBottom]);
|
||||||
|
|
||||||
|
const disableAutoScroll = useCallback(() => {
|
||||||
|
const atBottom = scrollRef.current
|
||||||
|
? checkIsAtBottom(scrollRef.current)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
// Only disable if not at bottom
|
||||||
|
if (!atBottom) {
|
||||||
|
userHasScrolled.current = true;
|
||||||
|
setScrollState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
autoScrollEnabled: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [checkIsAtBottom]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scrollRef,
|
||||||
|
isAtBottom: scrollState.isAtBottom,
|
||||||
|
autoScrollEnabled: scrollState.autoScrollEnabled,
|
||||||
|
scrollToBottom: () => scrollToBottom(false),
|
||||||
|
disableAutoScroll,
|
||||||
|
};
|
||||||
|
}
|
45
src/components/ui/chat/message-loading.tsx
Normal file
45
src/components/ui/chat/message-loading.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
// @hidden
|
||||||
|
export default function MessageLoading() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="text-foreground"
|
||||||
|
>
|
||||||
|
<circle cx="4" cy="12" r="2" fill="currentColor">
|
||||||
|
<animate
|
||||||
|
id="spinner_qFRN"
|
||||||
|
begin="0;spinner_OcgL.end+0.25s"
|
||||||
|
attributeName="cy"
|
||||||
|
calcMode="spline"
|
||||||
|
dur="0.6s"
|
||||||
|
values="12;6;12"
|
||||||
|
keySplines=".33,.66,.66,1;.33,0,.66,.33"
|
||||||
|
/>
|
||||||
|
</circle>
|
||||||
|
<circle cx="12" cy="12" r="2" fill="currentColor">
|
||||||
|
<animate
|
||||||
|
begin="spinner_qFRN.begin+0.1s"
|
||||||
|
attributeName="cy"
|
||||||
|
calcMode="spline"
|
||||||
|
dur="0.6s"
|
||||||
|
values="12;6;12"
|
||||||
|
keySplines=".33,.66,.66,1;.33,0,.66,.33"
|
||||||
|
/>
|
||||||
|
</circle>
|
||||||
|
<circle cx="20" cy="12" r="2" fill="currentColor">
|
||||||
|
<animate
|
||||||
|
id="spinner_OcgL"
|
||||||
|
begin="spinner_qFRN.begin+0.2s"
|
||||||
|
attributeName="cy"
|
||||||
|
calcMode="spline"
|
||||||
|
dur="0.6s"
|
||||||
|
values="12;6;12"
|
||||||
|
keySplines=".33,.66,.66,1;.33,0,.66,.33"
|
||||||
|
/>
|
||||||
|
</circle>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
45
src/components/ui/resizable.tsx
Normal file
45
src/components/ui/resizable.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { GripVertical } from "lucide-react"
|
||||||
|
import * as ResizablePrimitive from "react-resizable-panels"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ResizablePanelGroup = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||||
|
<ResizablePrimitive.PanelGroup
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ResizablePanel = ResizablePrimitive.Panel
|
||||||
|
|
||||||
|
const ResizableHandle = ({
|
||||||
|
withHandle,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||||
|
withHandle?: boolean
|
||||||
|
}) => (
|
||||||
|
<ResizablePrimitive.PanelResizeHandle
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{withHandle && (
|
||||||
|
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||||
|
<GripVertical className="h-2.5 w-2.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePrimitive.PanelResizeHandle>
|
||||||
|
)
|
||||||
|
|
||||||
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
@ -1,11 +1,11 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Tabs = TabsPrimitive.Root
|
const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
const TabsList = React.forwardRef<
|
const TabsList = React.forwardRef<
|
||||||
React.ElementRef<typeof TabsPrimitive.List>,
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
@ -14,13 +14,13 @@ const TabsList = React.forwardRef<
|
|||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
"inline-flex items-center justify-center rounded-lg bg-muted p-0.5 text-muted-foreground/70",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TabsList.displayName = TabsPrimitive.List.displayName
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||||
|
|
||||||
const TabsTrigger = React.forwardRef<
|
const TabsTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
@ -29,13 +29,13 @@ const TabsTrigger = React.forwardRef<
|
|||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium outline-offset-2 transition-all hover:text-muted-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm data-[state=active]:shadow-black/5",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
const TabsContent = React.forwardRef<
|
const TabsContent = React.forwardRef<
|
||||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
@ -44,12 +44,12 @@ const TabsContent = React.forwardRef<
|
|||||||
<TabsPrimitive.Content
|
<TabsPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"mt-2 outline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
22
src/components/ui/textarea.tsx
Normal file
22
src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<
|
||||||
|
HTMLTextAreaElement,
|
||||||
|
React.ComponentProps<"textarea">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
45
src/components/ui/toggle.tsx
Normal file
45
src/components/ui/toggle.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const toggleVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-2 min-w-9",
|
||||||
|
sm: "h-8 px-1.5 min-w-8",
|
||||||
|
lg: "h-10 px-2.5 min-w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Toggle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||||
|
VariantProps<typeof toggleVariants>
|
||||||
|
>(({ className, variant, size, ...props }, ref) => (
|
||||||
|
<TogglePrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toggleVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
Toggle.displayName = TogglePrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Toggle, toggleVariants }
|
Loading…
Reference in New Issue
Block a user