mirror of
https://litchi.icu/ngc2207/judge.git
synced 2025-05-18 16:26:44 +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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^1.0.13",
|
||||
"@auth/prisma-adapter": "^2.7.4",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@prisma/client": "^6.1.0",
|
||||
@ -19,6 +20,8 @@
|
||||
"@radix-ui/react-slider": "^1.2.2",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toggle": "^1.1.1",
|
||||
"ai": "^4.0.27",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"devicons-react": "^1.4.0",
|
||||
@ -29,9 +32,11 @@
|
||||
"monaco-languageclient": "5.0.1",
|
||||
"next": "15.1.3",
|
||||
"next-auth": "^5.0.0-beta.25",
|
||||
"next-themes": "^0.4.4",
|
||||
"normalize-url": "~8.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"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"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bird">
|
||||
<path d="M16 7h.01" />
|
||||
<path d="M3.4 18H12a8 8 0 0 0 8-8V7a4 4 0 0 0-7.28-2.3L2 20" />
|
||||
<path d="m20 7 2 .5-2 .5" />
|
||||
<path d="M10 18v3" />
|
||||
<path d="M14 17.75V21" />
|
||||
<path d="M7 18a6 6 0 0 0 3.84-10.61" />
|
||||
<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-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>
|
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 type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
||||
|
||||
@ -21,7 +22,16 @@ export default function RootLayout({
|
||||
<body
|
||||
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>
|
||||
</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<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
@ -14,13 +14,13 @@ const TabsList = React.forwardRef<
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
"inline-flex items-center justify-center rounded-lg bg-muted p-0.5 text-muted-foreground/70",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
@ -29,13 +29,13 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
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",
|
||||
className
|
||||
"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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
@ -44,12 +44,12 @@ const TabsContent = React.forwardRef<
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
"mt-2 outline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70",
|
||||
className,
|
||||
)}
|
||||
{...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