mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
Build temetro app: dark theme, clinical sidebar, AI chat, settings page
- Linear-inspired dark theme (app/globals.css), forced dark in layout
- Clinical sidebar (components/sidebar-02) with temetro brand
- AI chat UI (components/chat): Cursor-style input, UI-only mock replies
- Settings page (components/settings): Polar-style tabs/sections, UI only
- Route-group app shell: app/(app)/{layout,page,settings}
- shadcn (Base UI) + ai-elements component libraries
- CLAUDE.md: architecture notes + per-folder commit policy
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1,68 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
@AGENTS.md
|
||||
|
||||
> The import above is load-bearing: this is a **customized Next.js 16** with breaking changes vs.
|
||||
> public docs. Before writing any Next.js code (routing, metadata, `next/image`, `next/font`,
|
||||
> route handlers, config), read the relevant guide under `node_modules/next/dist/docs/01-app/`.
|
||||
|
||||
## What this is
|
||||
|
||||
`temetro` — an open-source AI chat that lets clinicians retrieve patient information in natural
|
||||
language. The product surface is currently **UI-only**: the chat appends a mock assistant reply
|
||||
(`components/chat/chat-panel.tsx`); there is no backend, model call, or patient data yet.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Next dev server (Turbopack) on http://localhost:3000
|
||||
npm run build # production build (runs a full TypeScript typecheck — see caveat below)
|
||||
npm run start # serve the production build
|
||||
npm run lint # eslint (eslint-config-next: core-web-vitals + typescript)
|
||||
```
|
||||
|
||||
There is **no test runner** configured (no `test` script, no vitest/jest/playwright). Don't invent
|
||||
test commands; verify changes by running the dev server.
|
||||
|
||||
**Version control:** this folder is its own git repo. Commit after every change
|
||||
(`git -C . add -A && git commit`), with the `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`
|
||||
trailer. See the root `../CLAUDE.md` for the project-wide per-folder commit policy.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Stack:** Next.js 16.2.6 (App Router) · React 19 · TypeScript · Tailwind CSS **v4**. Path alias
|
||||
`@/*` → repo root (`tsconfig.json`). `cn()` (clsx + tailwind-merge) lives in `lib/utils.ts`.
|
||||
- **`app/`** — App Router. `app/page.tsx` is the product chat page (sidebar + chat panel);
|
||||
`app/layout.tsx` **forces dark mode** by putting `dark` on `<html>` and loads the fonts.
|
||||
- **`components/ui/`** — shadcn components, but built on **Base UI (`@base-ui/react`), not Radix.**
|
||||
APIs differ: composition uses the `render` prop and `useRender`/`mergeProps`, not `asChild`.
|
||||
Match existing files when adding/editing primitives. Config in `components.json` (style
|
||||
`base-luma`, baseColor `neutral`).
|
||||
- **`components/ai-elements/`** — a large AI-chat primitive library (`PromptInput`, `Conversation`,
|
||||
`Message`, `Suggestion`, etc.) typed against **AI SDK v6** (`ai` package: `UIMessage`,
|
||||
`ChatStatus`, `FileUIPart`). Note: `@ai-sdk/react` (`useChat`) is **not installed** — chat state
|
||||
is managed with local React state.
|
||||
- **`components/sidebar-02/`** — the dashboard sidebar (`SidebarProvider` / `Sidebar` /
|
||||
`SidebarInset` from `components/ui/sidebar.tsx`). `app-sidebar.tsx` holds the nav/teams config.
|
||||
- **`components/chat/`** — the product chat UI. `chat-panel.tsx` owns message state + empty/active
|
||||
layouts; `chat-input.tsx` is a bespoke (non–ai-elements) input matching a specific design.
|
||||
|
||||
## Theming
|
||||
|
||||
Tailwind v4 with `@theme inline` and **oklch CSS variables** in `app/globals.css` (a
|
||||
Linear-inspired dark palette; `--primary` is indigo `#5e6ad2`). The app is dark-only. The radius
|
||||
scale (`rounded-2xl` … `rounded-4xl`) is derived from `--radius`, so those utilities are larger than
|
||||
stock Tailwind.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `npm run build` currently **fails its typecheck** on a pre-existing error in
|
||||
`components/ai-elements/attachments.tsx` (`openDelay` prop). `npm run dev` (Turbopack) is
|
||||
unaffected because it only compiles the imported route and doesn't typecheck the whole tree.
|
||||
- **`lucide-react@1.17` dropped brand glyphs** (e.g. `Github`, `Discord`) — import them and you get
|
||||
a build error. Use inline SVGs instead.
|
||||
- A sibling **`../landing-page/`** directory is a copy of this app with a marketing landing page
|
||||
(`components/landing/`); it is a separate project, not part of this git repo.
|
||||
- Multiple lockfiles in the tree produce a harmless Turbopack "inferred workspace root" warning.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { DashboardSidebar } from "@/components/sidebar-02/app-sidebar";
|
||||
|
||||
export default function AppLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="relative flex h-dvh w-full">
|
||||
<DashboardSidebar />
|
||||
{children}
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { SidebarInset } from "@/components/ui/sidebar";
|
||||
import { ChatPanel } from "@/components/chat/chat-panel";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<SidebarInset className="flex flex-1 flex-col overflow-hidden">
|
||||
<ChatPanel />
|
||||
</SidebarInset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { SidebarInset } from "@/components/ui/sidebar";
|
||||
import { SettingsView } from "@/components/settings/settings-view";
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<SidebarInset className="flex flex-1 flex-col overflow-y-auto">
|
||||
<SettingsView />
|
||||
</SidebarInset>
|
||||
);
|
||||
}
|
||||
+121
-16
@@ -1,26 +1,131 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-heading: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
:root {
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.488 0.243 264.376);
|
||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--radius: 0.875rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.546 0.245 262.881);
|
||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
}
|
||||
|
||||
/* Linear-inspired dark palette: near-black canvas, #5e6ad2 indigo accent, hairline borders. */
|
||||
.dark {
|
||||
--background: oklch(0.146 0.005 277);
|
||||
--foreground: oklch(0.98 0.002 277);
|
||||
--card: oklch(0.193 0.005 277);
|
||||
--card-foreground: oklch(0.98 0.002 277);
|
||||
--popover: oklch(0.193 0.005 277);
|
||||
--popover-foreground: oklch(0.98 0.002 277);
|
||||
--primary: oklch(0.556 0.155 277);
|
||||
--primary-foreground: oklch(0.985 0.001 277);
|
||||
--secondary: oklch(0.225 0.006 277);
|
||||
--secondary-foreground: oklch(0.98 0.002 277);
|
||||
--muted: oklch(0.225 0.006 277);
|
||||
--muted-foreground: oklch(0.652 0.012 268);
|
||||
--accent: oklch(0.245 0.008 277);
|
||||
--accent-foreground: oklch(0.98 0.002 277);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 8%);
|
||||
--input: oklch(1 0 0 / 10%);
|
||||
--ring: oklch(0.556 0.155 277);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--sidebar: oklch(0.165 0.005 277);
|
||||
--sidebar-foreground: oklch(0.98 0.002 277);
|
||||
--sidebar-primary: oklch(0.556 0.155 277);
|
||||
--sidebar-primary-foreground: oklch(0.985 0.001 277);
|
||||
--sidebar-accent: oklch(0.245 0.008 277);
|
||||
--sidebar-accent-foreground: oklch(0.98 0.002 277);
|
||||
--sidebar-border: oklch(1 0 0 / 8%);
|
||||
--sidebar-ring: oklch(0.556 0.155 277);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Geist, Geist_Mono, Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const inter = Inter({subsets:['latin'],variable:'--font-sans'});
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -13,8 +16,9 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "temetro — AI assistant for clinicians",
|
||||
description:
|
||||
"Retrieve patient information by simply asking. The open-source AI assistant for clinicians.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -25,7 +29,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
className={cn("dark", "h-full", "antialiased", geistSans.variable, geistMono.variable, "font-sans", inter.variable)}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-luma",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {
|
||||
"@blocks-so": "https://blocks.so/r/{name}.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Tool } from "ai";
|
||||
import { BotIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { memo } from "react";
|
||||
|
||||
import { CodeBlock } from "./code-block";
|
||||
|
||||
export type AgentProps = ComponentProps<"div">;
|
||||
|
||||
export const Agent = memo(({ className, ...props }: AgentProps) => (
|
||||
<div
|
||||
className={cn("not-prose w-full rounded-md border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
export type AgentHeaderProps = ComponentProps<"div"> & {
|
||||
name: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export const AgentHeader = memo(
|
||||
({ className, name, model, ...props }: AgentHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-4 p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<BotIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">{name}</span>
|
||||
{model && (
|
||||
<Badge className="font-mono text-xs" variant="secondary">
|
||||
{model}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
export type AgentContentProps = ComponentProps<"div">;
|
||||
|
||||
export const AgentContent = memo(
|
||||
({ className, ...props }: AgentContentProps) => (
|
||||
<div className={cn("space-y-4 p-4 pt-0", className)} {...props} />
|
||||
)
|
||||
);
|
||||
|
||||
export type AgentInstructionsProps = ComponentProps<"div"> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const AgentInstructions = memo(
|
||||
({ className, children, ...props }: AgentInstructionsProps) => (
|
||||
<div className={cn("space-y-2", className)} {...props}>
|
||||
<span className="font-medium text-muted-foreground text-sm">
|
||||
Instructions
|
||||
</span>
|
||||
<div className="rounded-md bg-muted/50 p-3 text-muted-foreground text-sm">
|
||||
<p>{children}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
export type AgentToolsProps = ComponentProps<typeof Accordion>;
|
||||
|
||||
export const AgentTools = memo(({ className, ...props }: AgentToolsProps) => (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
<span className="font-medium text-muted-foreground text-sm">Tools</span>
|
||||
<Accordion className="rounded-md border" {...props} />
|
||||
</div>
|
||||
));
|
||||
|
||||
export type AgentToolProps = ComponentProps<typeof AccordionItem> & {
|
||||
tool: Tool;
|
||||
};
|
||||
|
||||
export const AgentTool = memo(
|
||||
({ className, tool, value, ...props }: AgentToolProps) => {
|
||||
const schema =
|
||||
"jsonSchema" in tool && tool.jsonSchema
|
||||
? tool.jsonSchema
|
||||
: tool.inputSchema;
|
||||
|
||||
return (
|
||||
<AccordionItem
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
value={value}
|
||||
{...props}
|
||||
>
|
||||
<AccordionTrigger className="px-3 py-2 text-sm hover:no-underline">
|
||||
{tool.description ?? "No description"}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3">
|
||||
<div className="rounded-md bg-muted/50">
|
||||
<CodeBlock code={JSON.stringify(schema, null, 2)} language="json" />
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type AgentOutputProps = ComponentProps<"div"> & {
|
||||
schema: string;
|
||||
};
|
||||
|
||||
export const AgentOutput = memo(
|
||||
({ className, schema, ...props }: AgentOutputProps) => (
|
||||
<div className={cn("space-y-2", className)} {...props}>
|
||||
<span className="font-medium text-muted-foreground text-sm">
|
||||
Output Schema
|
||||
</span>
|
||||
<div className="rounded-md bg-muted/50">
|
||||
<CodeBlock code={schema} language="typescript" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
Agent.displayName = "Agent";
|
||||
AgentHeader.displayName = "AgentHeader";
|
||||
AgentContent.displayName = "AgentContent";
|
||||
AgentInstructions.displayName = "AgentInstructions";
|
||||
AgentTools.displayName = "AgentTools";
|
||||
AgentTool.displayName = "AgentTool";
|
||||
AgentOutput.displayName = "AgentOutput";
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { XIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
|
||||
export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const Artifact = ({ className, ...props }: ArtifactProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ArtifactHeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ArtifactHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b bg-muted/50 px-4 py-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ArtifactCloseProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ArtifactClose = ({
|
||||
className,
|
||||
children,
|
||||
size = "sm",
|
||||
variant = "ghost",
|
||||
...props
|
||||
}: ArtifactCloseProps) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"size-8 p-0 text-muted-foreground hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <XIcon className="size-4" />}
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
|
||||
<p
|
||||
className={cn("font-medium text-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ArtifactDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const ArtifactDescription = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactDescriptionProps) => (
|
||||
<p className={cn("text-muted-foreground text-sm", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ArtifactActionsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ArtifactActions = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ArtifactActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
icon?: LucideIcon;
|
||||
};
|
||||
|
||||
export const ArtifactAction = ({
|
||||
tooltip,
|
||||
label,
|
||||
icon: Icon,
|
||||
children,
|
||||
className,
|
||||
size = "sm",
|
||||
variant = "ghost",
|
||||
...props
|
||||
}: ArtifactActionProps) => {
|
||||
const button = (
|
||||
<Button
|
||||
className={cn(
|
||||
"size-8 p-0 text-muted-foreground hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{Icon ? <Icon className="size-4" /> : children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
export type ArtifactContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ArtifactContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactContentProps) => (
|
||||
<div className={cn("flex-1 overflow-auto p-4", className)} {...props} />
|
||||
);
|
||||
@@ -0,0 +1,426 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FileUIPart, SourceDocumentUIPart } from "ai";
|
||||
import {
|
||||
FileTextIcon,
|
||||
GlobeIcon,
|
||||
ImageIcon,
|
||||
Music2Icon,
|
||||
PaperclipIcon,
|
||||
VideoIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useMemo } from "react";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentData =
|
||||
| (FileUIPart & { id: string })
|
||||
| (SourceDocumentUIPart & { id: string });
|
||||
|
||||
export type AttachmentMediaCategory =
|
||||
| "image"
|
||||
| "video"
|
||||
| "audio"
|
||||
| "document"
|
||||
| "source"
|
||||
| "unknown";
|
||||
|
||||
export type AttachmentVariant = "grid" | "inline" | "list";
|
||||
|
||||
const mediaCategoryIcons: Record<AttachmentMediaCategory, typeof ImageIcon> = {
|
||||
audio: Music2Icon,
|
||||
document: FileTextIcon,
|
||||
image: ImageIcon,
|
||||
source: GlobeIcon,
|
||||
unknown: PaperclipIcon,
|
||||
video: VideoIcon,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
export const getMediaCategory = (
|
||||
data: AttachmentData
|
||||
): AttachmentMediaCategory => {
|
||||
if (data.type === "source-document") {
|
||||
return "source";
|
||||
}
|
||||
|
||||
const mediaType = data.mediaType ?? "";
|
||||
|
||||
if (mediaType.startsWith("image/")) {
|
||||
return "image";
|
||||
}
|
||||
if (mediaType.startsWith("video/")) {
|
||||
return "video";
|
||||
}
|
||||
if (mediaType.startsWith("audio/")) {
|
||||
return "audio";
|
||||
}
|
||||
if (mediaType.startsWith("application/") || mediaType.startsWith("text/")) {
|
||||
return "document";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
export const getAttachmentLabel = (data: AttachmentData): string => {
|
||||
if (data.type === "source-document") {
|
||||
return data.title || data.filename || "Source";
|
||||
}
|
||||
|
||||
const category = getMediaCategory(data);
|
||||
return data.filename || (category === "image" ? "Image" : "Attachment");
|
||||
};
|
||||
|
||||
const renderAttachmentImage = (
|
||||
url: string,
|
||||
filename: string | undefined,
|
||||
isGrid: boolean
|
||||
) =>
|
||||
isGrid ? (
|
||||
<img
|
||||
alt={filename || "Image"}
|
||||
className="size-full object-cover"
|
||||
height={96}
|
||||
src={url}
|
||||
width={96}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
alt={filename || "Image"}
|
||||
className="size-full rounded object-cover"
|
||||
height={20}
|
||||
src={url}
|
||||
width={20}
|
||||
/>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Contexts
|
||||
// ============================================================================
|
||||
|
||||
interface AttachmentsContextValue {
|
||||
variant: AttachmentVariant;
|
||||
}
|
||||
|
||||
const AttachmentsContext = createContext<AttachmentsContextValue | null>(null);
|
||||
|
||||
interface AttachmentContextValue {
|
||||
data: AttachmentData;
|
||||
mediaCategory: AttachmentMediaCategory;
|
||||
onRemove?: () => void;
|
||||
variant: AttachmentVariant;
|
||||
}
|
||||
|
||||
const AttachmentContext = createContext<AttachmentContextValue | null>(null);
|
||||
|
||||
// ============================================================================
|
||||
// Hooks
|
||||
// ============================================================================
|
||||
|
||||
export const useAttachmentsContext = () =>
|
||||
useContext(AttachmentsContext) ?? { variant: "grid" as const };
|
||||
|
||||
export const useAttachmentContext = () => {
|
||||
const ctx = useContext(AttachmentContext);
|
||||
if (!ctx) {
|
||||
throw new Error("Attachment components must be used within <Attachment>");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Attachments - Container
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentsProps = HTMLAttributes<HTMLDivElement> & {
|
||||
variant?: AttachmentVariant;
|
||||
};
|
||||
|
||||
export const Attachments = ({
|
||||
variant = "grid",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AttachmentsProps) => {
|
||||
const contextValue = useMemo(() => ({ variant }), [variant]);
|
||||
|
||||
return (
|
||||
<AttachmentsContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start",
|
||||
variant === "list" ? "flex-col gap-2" : "flex-wrap gap-2",
|
||||
variant === "grid" && "ml-auto w-fit",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AttachmentsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Attachment - Item
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
data: AttachmentData;
|
||||
onRemove?: () => void;
|
||||
};
|
||||
|
||||
export const Attachment = ({
|
||||
data,
|
||||
onRemove,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AttachmentProps) => {
|
||||
const { variant } = useAttachmentsContext();
|
||||
const mediaCategory = getMediaCategory(data);
|
||||
|
||||
const contextValue = useMemo<AttachmentContextValue>(
|
||||
() => ({ data, mediaCategory, onRemove, variant }),
|
||||
[data, mediaCategory, onRemove, variant]
|
||||
);
|
||||
|
||||
return (
|
||||
<AttachmentContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"group relative",
|
||||
variant === "grid" && "size-24 overflow-hidden rounded-lg",
|
||||
variant === "inline" && [
|
||||
"flex h-8 cursor-pointer select-none items-center gap-1.5",
|
||||
"rounded-md border border-border px-1.5",
|
||||
"font-medium text-sm transition-all",
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
],
|
||||
variant === "list" && [
|
||||
"flex w-full items-center gap-3 rounded-lg border p-3",
|
||||
"hover:bg-accent/50",
|
||||
],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AttachmentContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// AttachmentPreview - Media preview
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentPreviewProps = HTMLAttributes<HTMLDivElement> & {
|
||||
fallbackIcon?: ReactNode;
|
||||
};
|
||||
|
||||
export const AttachmentPreview = ({
|
||||
fallbackIcon,
|
||||
className,
|
||||
...props
|
||||
}: AttachmentPreviewProps) => {
|
||||
const { data, mediaCategory, variant } = useAttachmentContext();
|
||||
|
||||
const iconSize = variant === "inline" ? "size-3" : "size-4";
|
||||
|
||||
const renderIcon = (Icon: typeof ImageIcon) => (
|
||||
<Icon className={cn(iconSize, "text-muted-foreground")} />
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (mediaCategory === "image" && data.type === "file" && data.url) {
|
||||
return renderAttachmentImage(data.url, data.filename, variant === "grid");
|
||||
}
|
||||
|
||||
if (mediaCategory === "video" && data.type === "file" && data.url) {
|
||||
return <video className="size-full object-cover" muted src={data.url} />;
|
||||
}
|
||||
|
||||
const Icon = mediaCategoryIcons[mediaCategory];
|
||||
return fallbackIcon ?? renderIcon(Icon);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center overflow-hidden",
|
||||
variant === "grid" && "size-full bg-muted",
|
||||
variant === "inline" && "size-5 rounded bg-background",
|
||||
variant === "list" && "size-12 rounded bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// AttachmentInfo - Name and type display
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentInfoProps = HTMLAttributes<HTMLDivElement> & {
|
||||
showMediaType?: boolean;
|
||||
};
|
||||
|
||||
export const AttachmentInfo = ({
|
||||
showMediaType = false,
|
||||
className,
|
||||
...props
|
||||
}: AttachmentInfoProps) => {
|
||||
const { data, variant } = useAttachmentContext();
|
||||
const label = getAttachmentLabel(data);
|
||||
|
||||
if (variant === "grid") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0 flex-1", className)} {...props}>
|
||||
<span className="block truncate">{label}</span>
|
||||
{showMediaType && data.mediaType && (
|
||||
<span className="block truncate text-muted-foreground text-xs">
|
||||
{data.mediaType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// AttachmentRemove - Remove button
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentRemoveProps = ComponentProps<typeof Button> & {
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const AttachmentRemove = ({
|
||||
label = "Remove",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AttachmentRemoveProps) => {
|
||||
const { onRemove, variant } = useAttachmentContext();
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onRemove?.();
|
||||
},
|
||||
[onRemove]
|
||||
);
|
||||
|
||||
if (!onRemove) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
variant === "grid" && [
|
||||
"absolute top-2 right-2 size-6 rounded-full p-0",
|
||||
"bg-background/80 backdrop-blur-sm",
|
||||
"opacity-0 transition-opacity group-hover:opacity-100",
|
||||
"hover:bg-background",
|
||||
"[&>svg]:size-3",
|
||||
],
|
||||
variant === "inline" && [
|
||||
"size-5 rounded p-0",
|
||||
"opacity-0 transition-opacity group-hover:opacity-100",
|
||||
"[&>svg]:size-2.5",
|
||||
],
|
||||
variant === "list" && ["size-8 shrink-0 rounded p-0", "[&>svg]:size-4"],
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <XIcon />}
|
||||
<span className="sr-only">{label}</span>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// AttachmentHoverCard - Hover preview
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard>;
|
||||
|
||||
export const AttachmentHoverCard = ({
|
||||
openDelay = 0,
|
||||
closeDelay = 0,
|
||||
...props
|
||||
}: AttachmentHoverCardProps) => (
|
||||
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
|
||||
);
|
||||
|
||||
export type AttachmentHoverCardTriggerProps = ComponentProps<
|
||||
typeof HoverCardTrigger
|
||||
>;
|
||||
|
||||
export const AttachmentHoverCardTrigger = (
|
||||
props: AttachmentHoverCardTriggerProps
|
||||
) => <HoverCardTrigger {...props} />;
|
||||
|
||||
export type AttachmentHoverCardContentProps = ComponentProps<
|
||||
typeof HoverCardContent
|
||||
>;
|
||||
|
||||
export const AttachmentHoverCardContent = ({
|
||||
align = "start",
|
||||
className,
|
||||
...props
|
||||
}: AttachmentHoverCardContentProps) => (
|
||||
<HoverCardContent
|
||||
align={align}
|
||||
className={cn("w-auto p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// AttachmentEmpty - Empty state
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentEmptyProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const AttachmentEmpty = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AttachmentEmptyProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center p-4 text-muted-foreground text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? "No attachments"}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ButtonGroup,
|
||||
ButtonGroupText,
|
||||
} from "@/components/ui/button-group";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Experimental_SpeechResult as SpeechResult } from "ai";
|
||||
import {
|
||||
MediaControlBar,
|
||||
MediaController,
|
||||
MediaDurationDisplay,
|
||||
MediaMuteButton,
|
||||
MediaPlayButton,
|
||||
MediaSeekBackwardButton,
|
||||
MediaSeekForwardButton,
|
||||
MediaTimeDisplay,
|
||||
MediaTimeRange,
|
||||
MediaVolumeRange,
|
||||
} from "media-chrome/react";
|
||||
import type { ComponentProps, CSSProperties } from "react";
|
||||
|
||||
export type AudioPlayerProps = Omit<
|
||||
ComponentProps<typeof MediaController>,
|
||||
"audio"
|
||||
>;
|
||||
|
||||
export const AudioPlayer = ({
|
||||
children,
|
||||
style,
|
||||
...props
|
||||
}: AudioPlayerProps) => (
|
||||
<MediaController
|
||||
audio
|
||||
data-slot="audio-player"
|
||||
style={
|
||||
{
|
||||
"--media-background-color": "transparent",
|
||||
"--media-button-icon-height": "1rem",
|
||||
"--media-button-icon-width": "1rem",
|
||||
"--media-control-background": "transparent",
|
||||
"--media-control-hover-background": "var(--color-accent)",
|
||||
"--media-control-padding": "0",
|
||||
"--media-font": "var(--font-sans)",
|
||||
"--media-font-size": "10px",
|
||||
"--media-icon-color": "currentColor",
|
||||
"--media-preview-time-background": "var(--color-background)",
|
||||
"--media-preview-time-border-radius": "var(--radius-md)",
|
||||
"--media-preview-time-text-shadow": "none",
|
||||
"--media-primary-color": "var(--color-primary)",
|
||||
"--media-range-bar-color": "var(--color-primary)",
|
||||
"--media-range-track-background": "var(--color-secondary)",
|
||||
"--media-secondary-color": "var(--color-secondary)",
|
||||
"--media-text-color": "var(--color-foreground)",
|
||||
"--media-tooltip-arrow-display": "none",
|
||||
"--media-tooltip-background": "var(--color-background)",
|
||||
"--media-tooltip-border-radius": "var(--radius-md)",
|
||||
...style,
|
||||
} as CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</MediaController>
|
||||
);
|
||||
|
||||
export type AudioPlayerElementProps = Omit<ComponentProps<"audio">, "src"> &
|
||||
(
|
||||
| {
|
||||
data: SpeechResult["audio"];
|
||||
}
|
||||
| {
|
||||
src: string;
|
||||
}
|
||||
);
|
||||
|
||||
export const AudioPlayerElement = ({ ...props }: AudioPlayerElementProps) => (
|
||||
// oxlint-disable-next-line eslint-plugin-jsx-a11y(media-has-caption) -- audio player captions are provided by consumer
|
||||
<audio
|
||||
data-slot="audio-player-element"
|
||||
slot="media"
|
||||
src={
|
||||
"src" in props
|
||||
? props.src
|
||||
: `data:${props.data.mediaType};base64,${props.data.base64}`
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type AudioPlayerControlBarProps = ComponentProps<typeof MediaControlBar>;
|
||||
|
||||
export const AudioPlayerControlBar = ({
|
||||
children,
|
||||
...props
|
||||
}: AudioPlayerControlBarProps) => (
|
||||
<MediaControlBar data-slot="audio-player-control-bar" {...props}>
|
||||
<ButtonGroup orientation="horizontal">{children}</ButtonGroup>
|
||||
</MediaControlBar>
|
||||
);
|
||||
|
||||
export type AudioPlayerPlayButtonProps = ComponentProps<typeof MediaPlayButton>;
|
||||
|
||||
export const AudioPlayerPlayButton = ({
|
||||
className,
|
||||
...props
|
||||
}: AudioPlayerPlayButtonProps) => (
|
||||
<Button size="icon-sm" variant="outline" render={<MediaPlayButton className={cn("bg-transparent", className)} data-slot="audio-player-play-button" {...props} />}></Button>
|
||||
);
|
||||
|
||||
export type AudioPlayerSeekBackwardButtonProps = ComponentProps<
|
||||
typeof MediaSeekBackwardButton
|
||||
>;
|
||||
|
||||
export const AudioPlayerSeekBackwardButton = ({
|
||||
seekOffset = 10,
|
||||
...props
|
||||
}: AudioPlayerSeekBackwardButtonProps) => (
|
||||
<Button size="icon-sm" variant="outline" render={<MediaSeekBackwardButton data-slot="audio-player-seek-backward-button" seekOffset={seekOffset} {...props} />}></Button>
|
||||
);
|
||||
|
||||
export type AudioPlayerSeekForwardButtonProps = ComponentProps<
|
||||
typeof MediaSeekForwardButton
|
||||
>;
|
||||
|
||||
export const AudioPlayerSeekForwardButton = ({
|
||||
seekOffset = 10,
|
||||
...props
|
||||
}: AudioPlayerSeekForwardButtonProps) => (
|
||||
<Button size="icon-sm" variant="outline" render={<MediaSeekForwardButton data-slot="audio-player-seek-forward-button" seekOffset={seekOffset} {...props} />}></Button>
|
||||
);
|
||||
|
||||
export type AudioPlayerTimeDisplayProps = ComponentProps<
|
||||
typeof MediaTimeDisplay
|
||||
>;
|
||||
|
||||
export const AudioPlayerTimeDisplay = ({
|
||||
className,
|
||||
...props
|
||||
}: AudioPlayerTimeDisplayProps) => (
|
||||
<ButtonGroupText className="bg-transparent" render={<MediaTimeDisplay className={cn("tabular-nums", className)} data-slot="audio-player-time-display" {...props} />}></ButtonGroupText>
|
||||
);
|
||||
|
||||
export type AudioPlayerTimeRangeProps = ComponentProps<typeof MediaTimeRange>;
|
||||
|
||||
export const AudioPlayerTimeRange = ({
|
||||
className,
|
||||
...props
|
||||
}: AudioPlayerTimeRangeProps) => (
|
||||
<ButtonGroupText className="bg-transparent" render={<MediaTimeRange className={cn("", className)} data-slot="audio-player-time-range" {...props} />}></ButtonGroupText>
|
||||
);
|
||||
|
||||
export type AudioPlayerDurationDisplayProps = ComponentProps<
|
||||
typeof MediaDurationDisplay
|
||||
>;
|
||||
|
||||
export const AudioPlayerDurationDisplay = ({
|
||||
className,
|
||||
...props
|
||||
}: AudioPlayerDurationDisplayProps) => (
|
||||
<ButtonGroupText className="bg-transparent" render={<MediaDurationDisplay className={cn("tabular-nums", className)} data-slot="audio-player-duration-display" {...props} />}></ButtonGroupText>
|
||||
);
|
||||
|
||||
export type AudioPlayerMuteButtonProps = ComponentProps<typeof MediaMuteButton>;
|
||||
|
||||
export const AudioPlayerMuteButton = ({
|
||||
className,
|
||||
...props
|
||||
}: AudioPlayerMuteButtonProps) => (
|
||||
<ButtonGroupText className="bg-transparent" render={<MediaMuteButton className={cn("", className)} data-slot="audio-player-mute-button" {...props} />}></ButtonGroupText>
|
||||
);
|
||||
|
||||
export type AudioPlayerVolumeRangeProps = ComponentProps<
|
||||
typeof MediaVolumeRange
|
||||
>;
|
||||
|
||||
export const AudioPlayerVolumeRange = ({
|
||||
className,
|
||||
...props
|
||||
}: AudioPlayerVolumeRangeProps) => (
|
||||
<ButtonGroupText className="bg-transparent" render={<MediaVolumeRange className={cn("", className)} data-slot="audio-player-volume-range" {...props} />}></ButtonGroupText>
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReactFlowProps } from "@xyflow/react";
|
||||
import { Background, ReactFlow } from "@xyflow/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
type CanvasProps = ReactFlowProps & {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const deleteKeyCode = ["Backspace", "Delete"];
|
||||
|
||||
export const Canvas = ({ children, ...props }: CanvasProps) => (
|
||||
<ReactFlow
|
||||
deleteKeyCode={deleteKeyCode}
|
||||
fitView
|
||||
panOnDrag={false}
|
||||
panOnScroll
|
||||
selectionOnDrag={true}
|
||||
zoomOnDoubleClick={false}
|
||||
{...props}
|
||||
>
|
||||
<Background bgColor="var(--sidebar)" />
|
||||
{children}
|
||||
</ReactFlow>
|
||||
);
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { BrainIcon, ChevronDownIcon, DotIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, memo, useContext, useMemo } from "react";
|
||||
|
||||
interface ChainOfThoughtContextValue {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const useChainOfThought = () => {
|
||||
const context = useContext(ChainOfThoughtContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"ChainOfThought components must be used within ChainOfThought"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ChainOfThoughtProps = ComponentProps<"div"> & {
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const ChainOfThought = memo(
|
||||
({
|
||||
className,
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
...props
|
||||
}: ChainOfThoughtProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
prop: open,
|
||||
});
|
||||
|
||||
const chainOfThoughtContext = useMemo(
|
||||
() => ({ isOpen, setIsOpen }),
|
||||
[isOpen, setIsOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
|
||||
<div className={cn("not-prose w-full space-y-4", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</ChainOfThoughtContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ChainOfThoughtHeaderProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
>;
|
||||
|
||||
export const ChainOfThoughtHeader = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtHeaderProps) => {
|
||||
const { isOpen, setIsOpen } = useChainOfThought();
|
||||
|
||||
return (
|
||||
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<BrainIcon className="size-4" />
|
||||
<span className="flex-1 text-left">
|
||||
{children ?? "Chain of Thought"}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
|
||||
icon?: LucideIcon;
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
status?: "complete" | "active" | "pending";
|
||||
};
|
||||
|
||||
const stepStatusStyles = {
|
||||
active: "text-foreground",
|
||||
complete: "text-muted-foreground",
|
||||
pending: "text-muted-foreground/50",
|
||||
};
|
||||
|
||||
export const ChainOfThoughtStep = memo(
|
||||
({
|
||||
className,
|
||||
icon: Icon = DotIcon,
|
||||
label,
|
||||
description,
|
||||
status = "complete",
|
||||
children,
|
||||
...props
|
||||
}: ChainOfThoughtStepProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2 text-sm",
|
||||
stepStatusStyles[status],
|
||||
"fade-in-0 slide-in-from-top-2 animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative mt-0.5">
|
||||
<Icon className="size-4" />
|
||||
<div className="absolute top-7 bottom-0 left-1/2 -mx-px w-px bg-border" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 overflow-hidden">
|
||||
<div>{label}</div>
|
||||
{description && (
|
||||
<div className="text-muted-foreground text-xs">{description}</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
|
||||
|
||||
export const ChainOfThoughtSearchResults = memo(
|
||||
({ className, ...props }: ChainOfThoughtSearchResultsProps) => (
|
||||
<div
|
||||
className={cn("flex flex-wrap items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
|
||||
|
||||
export const ChainOfThoughtSearchResult = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
|
||||
<Badge
|
||||
className={cn("gap-1 px-2 py-0.5 font-normal text-xs", className)}
|
||||
variant="secondary"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
)
|
||||
);
|
||||
|
||||
export type ChainOfThoughtContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
>;
|
||||
|
||||
export const ChainOfThoughtContent = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtContentProps) => {
|
||||
const { isOpen } = useChainOfThought();
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen}>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-2 space-y-3",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
export const ChainOfThoughtImage = memo(
|
||||
({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
|
||||
<div className={cn("mt-2 space-y-2", className)} {...props}>
|
||||
<div className="relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg bg-muted p-3">
|
||||
{children}
|
||||
</div>
|
||||
{caption && <p className="text-muted-foreground text-xs">{caption}</p>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
ChainOfThought.displayName = "ChainOfThought";
|
||||
ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader";
|
||||
ChainOfThoughtStep.displayName = "ChainOfThoughtStep";
|
||||
ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults";
|
||||
ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult";
|
||||
ChainOfThoughtContent.displayName = "ChainOfThoughtContent";
|
||||
ChainOfThoughtImage.displayName = "ChainOfThoughtImage";
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LucideProps } from "lucide-react";
|
||||
import { BookmarkIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
|
||||
export type CheckpointProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const Checkpoint = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CheckpointProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-0.5 overflow-hidden text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<Separator />
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CheckpointIconProps = LucideProps;
|
||||
|
||||
export const CheckpointIcon = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CheckpointIconProps) =>
|
||||
children ?? (
|
||||
<BookmarkIcon className={cn("size-4 shrink-0", className)} {...props} />
|
||||
);
|
||||
|
||||
export type CheckpointTriggerProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
export const CheckpointTrigger = ({
|
||||
children,
|
||||
variant = "ghost",
|
||||
size = "sm",
|
||||
tooltip,
|
||||
...props
|
||||
}: CheckpointTriggerProps) =>
|
||||
tooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Button size={size} type="button" variant={variant} {...props} />}>{children}</TooltipTrigger>
|
||||
<TooltipContent align="start" side="bottom">
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
@@ -0,0 +1,562 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type {
|
||||
BundledLanguage,
|
||||
BundledTheme,
|
||||
HighlighterGeneric,
|
||||
ThemedToken,
|
||||
} from "shiki";
|
||||
import { createHighlighter } from "shiki";
|
||||
|
||||
// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
|
||||
// oxlint-disable-next-line eslint(no-bitwise)
|
||||
const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1;
|
||||
// oxlint-disable-next-line eslint(no-bitwise)
|
||||
const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2;
|
||||
const isUnderline = (fontStyle: number | undefined) =>
|
||||
// oxlint-disable-next-line eslint(no-bitwise)
|
||||
fontStyle && fontStyle & 4;
|
||||
|
||||
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
|
||||
interface KeyedToken {
|
||||
token: ThemedToken;
|
||||
key: string;
|
||||
}
|
||||
interface KeyedLine {
|
||||
tokens: KeyedToken[];
|
||||
key: string;
|
||||
}
|
||||
|
||||
const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
|
||||
lines.map((line, lineIdx) => ({
|
||||
key: `line-${lineIdx}`,
|
||||
tokens: line.map((token, tokenIdx) => ({
|
||||
key: `line-${lineIdx}-${tokenIdx}`,
|
||||
token,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Token rendering component
|
||||
const TokenSpan = ({ token }: { token: ThemedToken }) => (
|
||||
<span
|
||||
className="dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)]"
|
||||
style={
|
||||
{
|
||||
backgroundColor: token.bgColor,
|
||||
color: token.color,
|
||||
fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
|
||||
fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
|
||||
textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
|
||||
...token.htmlStyle,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{token.content}
|
||||
</span>
|
||||
);
|
||||
|
||||
// Line number styles using CSS counters
|
||||
const LINE_NUMBER_CLASSES = cn(
|
||||
"block",
|
||||
"before:content-[counter(line)]",
|
||||
"before:inline-block",
|
||||
"before:[counter-increment:line]",
|
||||
"before:w-8",
|
||||
"before:mr-4",
|
||||
"before:text-right",
|
||||
"before:text-muted-foreground/50",
|
||||
"before:font-mono",
|
||||
"before:select-none"
|
||||
);
|
||||
|
||||
// Line rendering component
|
||||
const LineSpan = ({
|
||||
keyedLine,
|
||||
showLineNumbers,
|
||||
}: {
|
||||
keyedLine: KeyedLine;
|
||||
showLineNumbers: boolean;
|
||||
}) => (
|
||||
<span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
|
||||
{keyedLine.tokens.length === 0
|
||||
? "\n"
|
||||
: keyedLine.tokens.map(({ token, key }) => (
|
||||
<TokenSpan key={key} token={token} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
|
||||
// Types
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string;
|
||||
language: BundledLanguage;
|
||||
showLineNumbers?: boolean;
|
||||
};
|
||||
|
||||
interface TokenizedCode {
|
||||
tokens: ThemedToken[][];
|
||||
fg: string;
|
||||
bg: string;
|
||||
}
|
||||
|
||||
interface CodeBlockContextType {
|
||||
code: string;
|
||||
}
|
||||
|
||||
// Context
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: "",
|
||||
});
|
||||
|
||||
// Highlighter cache (singleton per language)
|
||||
const highlighterCache = new Map<
|
||||
string,
|
||||
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
|
||||
>();
|
||||
|
||||
// Token cache
|
||||
const tokensCache = new Map<string, TokenizedCode>();
|
||||
|
||||
// Subscribers for async token updates
|
||||
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
|
||||
|
||||
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
|
||||
const start = code.slice(0, 100);
|
||||
const end = code.length > 100 ? code.slice(-100) : "";
|
||||
return `${language}:${code.length}:${start}:${end}`;
|
||||
};
|
||||
|
||||
const getHighlighter = (
|
||||
language: BundledLanguage
|
||||
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
|
||||
const cached = highlighterCache.get(language);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const highlighterPromise = createHighlighter({
|
||||
langs: [language],
|
||||
themes: ["github-light", "github-dark"],
|
||||
});
|
||||
|
||||
highlighterCache.set(language, highlighterPromise);
|
||||
return highlighterPromise;
|
||||
};
|
||||
|
||||
// Create raw tokens for immediate display while highlighting loads
|
||||
const createRawTokens = (code: string): TokenizedCode => ({
|
||||
bg: "transparent",
|
||||
fg: "inherit",
|
||||
tokens: code.split("\n").map((line) =>
|
||||
line === ""
|
||||
? []
|
||||
: [
|
||||
{
|
||||
color: "inherit",
|
||||
content: line,
|
||||
} as ThemedToken,
|
||||
]
|
||||
),
|
||||
});
|
||||
|
||||
// Synchronous highlight with callback for async results
|
||||
export const highlightCode = (
|
||||
code: string,
|
||||
language: BundledLanguage,
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
|
||||
callback?: (result: TokenizedCode) => void
|
||||
): TokenizedCode | null => {
|
||||
const tokensCacheKey = getTokensCacheKey(code, language);
|
||||
|
||||
// Return cached result if available
|
||||
const cached = tokensCache.get(tokensCacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Subscribe callback if provided
|
||||
if (callback) {
|
||||
if (!subscribers.has(tokensCacheKey)) {
|
||||
subscribers.set(tokensCacheKey, new Set());
|
||||
}
|
||||
subscribers.get(tokensCacheKey)?.add(callback);
|
||||
}
|
||||
|
||||
// Start highlighting in background - fire-and-forget async pattern
|
||||
getHighlighter(language)
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
|
||||
.then((highlighter) => {
|
||||
const availableLangs = highlighter.getLoadedLanguages();
|
||||
const langToUse = availableLangs.includes(language) ? language : "text";
|
||||
|
||||
const result = highlighter.codeToTokens(code, {
|
||||
lang: langToUse,
|
||||
themes: {
|
||||
dark: "github-dark",
|
||||
light: "github-light",
|
||||
},
|
||||
});
|
||||
|
||||
const tokenized: TokenizedCode = {
|
||||
bg: result.bg ?? "transparent",
|
||||
fg: result.fg ?? "inherit",
|
||||
tokens: result.tokens,
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
tokensCache.set(tokensCacheKey, tokenized);
|
||||
|
||||
// Notify all subscribers
|
||||
const subs = subscribers.get(tokensCacheKey);
|
||||
if (subs) {
|
||||
for (const sub of subs) {
|
||||
sub(tokenized);
|
||||
}
|
||||
subscribers.delete(tokensCacheKey);
|
||||
}
|
||||
})
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks)
|
||||
.catch((error) => {
|
||||
console.error("Failed to highlight code:", error);
|
||||
subscribers.delete(tokensCacheKey);
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const CodeBlockBody = memo(
|
||||
({
|
||||
tokenized,
|
||||
showLineNumbers,
|
||||
className,
|
||||
}: {
|
||||
tokenized: TokenizedCode;
|
||||
showLineNumbers: boolean;
|
||||
className?: string;
|
||||
}) => {
|
||||
const preStyle = useMemo(
|
||||
() => ({
|
||||
backgroundColor: tokenized.bg,
|
||||
color: tokenized.fg,
|
||||
}),
|
||||
[tokenized.bg, tokenized.fg]
|
||||
);
|
||||
|
||||
const keyedLines = useMemo(
|
||||
() => addKeysToTokens(tokenized.tokens),
|
||||
[tokenized.tokens]
|
||||
);
|
||||
|
||||
return (
|
||||
<pre
|
||||
className={cn(
|
||||
"dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)] m-0 p-4 text-sm",
|
||||
className
|
||||
)}
|
||||
style={preStyle}
|
||||
>
|
||||
<code
|
||||
className={cn(
|
||||
"font-mono text-sm",
|
||||
showLineNumbers && "[counter-increment:line_0] [counter-reset:line]"
|
||||
)}
|
||||
>
|
||||
{keyedLines.map((keyedLine) => (
|
||||
<LineSpan
|
||||
key={keyedLine.key}
|
||||
keyedLine={keyedLine}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.tokenized === nextProps.tokenized &&
|
||||
prevProps.showLineNumbers === nextProps.showLineNumbers &&
|
||||
prevProps.className === nextProps.className
|
||||
);
|
||||
|
||||
CodeBlockBody.displayName = "CodeBlockBody";
|
||||
|
||||
export const CodeBlockContainer = ({
|
||||
className,
|
||||
language,
|
||||
style,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & { language: string }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
|
||||
className
|
||||
)}
|
||||
data-language={language}
|
||||
style={{
|
||||
containIntrinsicSize: "auto 200px",
|
||||
contentVisibility: "auto",
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const CodeBlockHeader = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CodeBlockTitle = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex items-center gap-2", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CodeBlockFilename = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLSpanElement>) => (
|
||||
<span className={cn("font-mono", className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export const CodeBlockActions = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("-my-1 -mr-1 flex items-center gap-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CodeBlockContent = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
}: {
|
||||
code: string;
|
||||
language: BundledLanguage;
|
||||
showLineNumbers?: boolean;
|
||||
}) => {
|
||||
// Memoized raw tokens for immediate display
|
||||
const rawTokens = useMemo(() => createRawTokens(code), [code]);
|
||||
|
||||
// Synchronous cache lookup — avoids setState in effect for cached results
|
||||
const syncTokens = useMemo(
|
||||
() => highlightCode(code, language) ?? rawTokens,
|
||||
[code, language, rawTokens]
|
||||
);
|
||||
|
||||
// Async highlighting result (populated after shiki loads)
|
||||
const [asyncTokens, setAsyncTokens] = useState<TokenizedCode | null>(null);
|
||||
const asyncKeyRef = useRef({ code, language });
|
||||
|
||||
// Invalidate stale async tokens synchronously during render
|
||||
if (
|
||||
asyncKeyRef.current.code !== code ||
|
||||
asyncKeyRef.current.language !== language
|
||||
) {
|
||||
asyncKeyRef.current = { code, language };
|
||||
setAsyncTokens(null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
highlightCode(code, language, (result) => {
|
||||
if (!cancelled) {
|
||||
setAsyncTokens(result);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, language]);
|
||||
|
||||
const tokenized = asyncTokens ?? syncTokens;
|
||||
|
||||
return (
|
||||
<div className="relative overflow-auto">
|
||||
<CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => {
|
||||
const contextValue = useMemo(() => ({ code }), [code]);
|
||||
|
||||
return (
|
||||
<CodeBlockContext.Provider value={contextValue}>
|
||||
<CodeBlockContainer className={className} language={language} {...props}>
|
||||
{children}
|
||||
<CodeBlockContent
|
||||
code={code}
|
||||
language={language}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
</CodeBlockContainer>
|
||||
</CodeBlockContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const CodeBlockCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<number>(0);
|
||||
const { code } = useContext(CodeBlockContext);
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isCopied) {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
timeoutRef.current = window.setTimeout(
|
||||
() => setIsCopied(false),
|
||||
timeout
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}, [code, onCopy, onError, timeout, isCopied]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>;
|
||||
|
||||
export const CodeBlockLanguageSelector = (
|
||||
props: CodeBlockLanguageSelectorProps
|
||||
) => <Select {...props} />;
|
||||
|
||||
export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
|
||||
typeof SelectTrigger
|
||||
>;
|
||||
|
||||
export const CodeBlockLanguageSelectorTrigger = ({
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockLanguageSelectorTriggerProps) => (
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"h-7 border-none bg-transparent px-2 text-xs shadow-none",
|
||||
className
|
||||
)}
|
||||
size="sm"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type CodeBlockLanguageSelectorValueProps = ComponentProps<
|
||||
typeof SelectValue
|
||||
>;
|
||||
|
||||
export const CodeBlockLanguageSelectorValue = (
|
||||
props: CodeBlockLanguageSelectorValueProps
|
||||
) => <SelectValue {...props} />;
|
||||
|
||||
export type CodeBlockLanguageSelectorContentProps = ComponentProps<
|
||||
typeof SelectContent
|
||||
>;
|
||||
|
||||
export const CodeBlockLanguageSelectorContent = ({
|
||||
align = "end",
|
||||
...props
|
||||
}: CodeBlockLanguageSelectorContentProps) => (
|
||||
<SelectContent align={align} {...props} />
|
||||
);
|
||||
|
||||
export type CodeBlockLanguageSelectorItemProps = ComponentProps<
|
||||
typeof SelectItem
|
||||
>;
|
||||
|
||||
export const CodeBlockLanguageSelectorItem = (
|
||||
props: CodeBlockLanguageSelectorItemProps
|
||||
) => <SelectItem {...props} />;
|
||||
@@ -0,0 +1,452 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
FileIcon,
|
||||
GitCommitIcon,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export type CommitProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Commit = ({ className, children, ...props }: CommitProps) => (
|
||||
<Collapsible
|
||||
className={cn("rounded-lg border bg-background", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
);
|
||||
|
||||
export type CommitHeaderProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
export const CommitHeader = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitHeaderProps) => (
|
||||
<CollapsibleTrigger {...props} render={<div className={cn(
|
||||
"group flex cursor-pointer items-center justify-between gap-4 p-3 text-left transition-colors hover:opacity-80",
|
||||
className
|
||||
)} />}>{children}</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type CommitHashProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const CommitHash = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitHashProps) => (
|
||||
<span className={cn("font-mono text-xs", className)} {...props}>
|
||||
<GitCommitIcon className="mr-1 inline-block size-3" />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type CommitMessageProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const CommitMessage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitMessageProps) => (
|
||||
<span className={cn("font-medium text-sm", className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type CommitMetadataProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const CommitMetadata = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitMetadataProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-muted-foreground text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CommitSeparatorProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const CommitSeparator = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitSeparatorProps) => (
|
||||
<span className={className} {...props}>
|
||||
{children ?? "•"}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type CommitInfoProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const CommitInfo = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitInfoProps) => (
|
||||
<div className={cn("flex flex-1 flex-col", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CommitAuthorProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const CommitAuthor = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitAuthorProps) => (
|
||||
<div className={cn("flex items-center", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CommitAuthorAvatarProps = ComponentProps<typeof Avatar> & {
|
||||
initials: string;
|
||||
};
|
||||
|
||||
export const CommitAuthorAvatar = ({
|
||||
initials,
|
||||
className,
|
||||
...props
|
||||
}: CommitAuthorAvatarProps) => (
|
||||
<Avatar className={cn("size-8", className)} {...props}>
|
||||
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
|
||||
export type CommitTimestampProps = HTMLAttributes<HTMLTimeElement> & {
|
||||
date: Date;
|
||||
};
|
||||
|
||||
const relativeTimeFormat = new Intl.RelativeTimeFormat("en", {
|
||||
numeric: "auto",
|
||||
});
|
||||
|
||||
const formatRelativeDate = (date: Date) => {
|
||||
const days = Math.round(
|
||||
(date.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
return relativeTimeFormat.format(days, "day");
|
||||
};
|
||||
|
||||
export const CommitTimestamp = ({
|
||||
date,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitTimestampProps) => {
|
||||
const [formatted, setFormatted] = useState("");
|
||||
|
||||
const updateFormatted = useCallback(() => {
|
||||
setFormatted(formatRelativeDate(date));
|
||||
}, [date]);
|
||||
|
||||
useEffect(() => {
|
||||
updateFormatted();
|
||||
}, [updateFormatted]);
|
||||
|
||||
return (
|
||||
<time
|
||||
className={cn("text-xs", className)}
|
||||
dateTime={date.toISOString()}
|
||||
{...props}
|
||||
>
|
||||
{children ?? formatted}
|
||||
</time>
|
||||
);
|
||||
};
|
||||
|
||||
export type CommitActionsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
const handleActionsClick = (e: React.MouseEvent) => e.stopPropagation();
|
||||
const handleActionsKeyDown = (e: React.KeyboardEvent) => e.stopPropagation();
|
||||
|
||||
export const CommitActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitActionsProps) => (
|
||||
<div
|
||||
className={cn("flex items-center gap-1", className)}
|
||||
onClick={handleActionsClick}
|
||||
onKeyDown={handleActionsKeyDown}
|
||||
role="group"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CommitCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
hash: string;
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const CommitCopyButton = ({
|
||||
hash,
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CommitCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<number>(0);
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isCopied) {
|
||||
await navigator.clipboard.writeText(hash);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
timeoutRef.current = window.setTimeout(
|
||||
() => setIsCopied(false),
|
||||
timeout
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}, [hash, onCopy, onError, timeout, isCopied]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("size-7 shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type CommitContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const CommitContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitContentProps) => (
|
||||
<CollapsibleContent className={cn("border-t p-3", className)} {...props}>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
);
|
||||
|
||||
export type CommitFilesProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const CommitFiles = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFilesProps) => (
|
||||
<div className={cn("space-y-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CommitFileProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const CommitFile = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFileProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CommitFileInfoProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const CommitFileInfo = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFileInfoProps) => (
|
||||
<div className={cn("flex min-w-0 items-center gap-2", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const fileStatusStyles = {
|
||||
added: "text-green-600 dark:text-green-400",
|
||||
deleted: "text-red-600 dark:text-red-400",
|
||||
modified: "text-yellow-600 dark:text-yellow-400",
|
||||
renamed: "text-blue-600 dark:text-blue-400",
|
||||
};
|
||||
|
||||
const fileStatusLabels = {
|
||||
added: "A",
|
||||
deleted: "D",
|
||||
modified: "M",
|
||||
renamed: "R",
|
||||
};
|
||||
|
||||
export type CommitFileStatusProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
status: "added" | "modified" | "deleted" | "renamed";
|
||||
};
|
||||
|
||||
export const CommitFileStatus = ({
|
||||
status,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFileStatusProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium font-mono text-xs",
|
||||
fileStatusStyles[status],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? fileStatusLabels[status]}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type CommitFileIconProps = ComponentProps<typeof FileIcon>;
|
||||
|
||||
export const CommitFileIcon = ({
|
||||
className,
|
||||
...props
|
||||
}: CommitFileIconProps) => (
|
||||
<FileIcon
|
||||
className={cn("size-3.5 shrink-0 text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type CommitFilePathProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const CommitFilePath = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFilePathProps) => (
|
||||
<span className={cn("truncate font-mono text-xs", className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type CommitFileChangesProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const CommitFileChanges = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFileChangesProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1 font-mono text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CommitFileAdditionsProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
count: number;
|
||||
};
|
||||
|
||||
export const CommitFileAdditions = ({
|
||||
count,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFileAdditionsProps) => {
|
||||
if (count <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("text-green-600 dark:text-green-400", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<PlusIcon className="inline-block size-3" />
|
||||
{count}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type CommitFileDeletionsProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
count: number;
|
||||
};
|
||||
|
||||
export const CommitFileDeletions = ({
|
||||
count,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFileDeletionsProps) => {
|
||||
if (count <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("text-red-600 dark:text-red-400", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<MinusIcon className="inline-block size-3" />
|
||||
{count}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
type ToolUIPartApproval =
|
||||
| {
|
||||
id: string;
|
||||
approved?: never;
|
||||
reason?: never;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
approved: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
approved: true;
|
||||
reason?: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
approved: true;
|
||||
reason?: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
approved: false;
|
||||
reason?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
interface ConfirmationContextValue {
|
||||
approval: ToolUIPartApproval;
|
||||
state: ToolUIPart["state"];
|
||||
}
|
||||
|
||||
const ConfirmationContext = createContext<ConfirmationContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const useConfirmation = () => {
|
||||
const context = useContext(ConfirmationContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Confirmation components must be used within Confirmation");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ConfirmationProps = ComponentProps<typeof Alert> & {
|
||||
approval?: ToolUIPartApproval;
|
||||
state: ToolUIPart["state"];
|
||||
};
|
||||
|
||||
export const Confirmation = ({
|
||||
className,
|
||||
approval,
|
||||
state,
|
||||
...props
|
||||
}: ConfirmationProps) => {
|
||||
const contextValue = useMemo(() => ({ approval, state }), [approval, state]);
|
||||
|
||||
if (!approval || state === "input-streaming" || state === "input-available") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmationContext.Provider value={contextValue}>
|
||||
<Alert className={cn("flex flex-col gap-2", className)} {...props} />
|
||||
</ConfirmationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type ConfirmationTitleProps = ComponentProps<typeof AlertDescription>;
|
||||
|
||||
export const ConfirmationTitle = ({
|
||||
className,
|
||||
...props
|
||||
}: ConfirmationTitleProps) => (
|
||||
<AlertDescription className={cn("inline", className)} {...props} />
|
||||
);
|
||||
|
||||
export interface ConfirmationRequestProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => {
|
||||
const { state } = useConfirmation();
|
||||
|
||||
// Only show when approval is requested
|
||||
if (state !== "approval-requested") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export interface ConfirmationAcceptedProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const ConfirmationAccepted = ({
|
||||
children,
|
||||
}: ConfirmationAcceptedProps) => {
|
||||
const { approval, state } = useConfirmation();
|
||||
|
||||
// Only show when approved and in response states
|
||||
if (
|
||||
!approval?.approved ||
|
||||
(state !== "approval-responded" &&
|
||||
state !== "output-denied" &&
|
||||
state !== "output-available")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export interface ConfirmationRejectedProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const ConfirmationRejected = ({
|
||||
children,
|
||||
}: ConfirmationRejectedProps) => {
|
||||
const { approval, state } = useConfirmation();
|
||||
|
||||
// Only show when rejected and in response states
|
||||
if (
|
||||
approval?.approved !== false ||
|
||||
(state !== "approval-responded" &&
|
||||
state !== "output-denied" &&
|
||||
state !== "output-available")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export type ConfirmationActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const ConfirmationActions = ({
|
||||
className,
|
||||
...props
|
||||
}: ConfirmationActionsProps) => {
|
||||
const { state } = useConfirmation();
|
||||
|
||||
// Only show when approval is requested
|
||||
if (state !== "approval-requested") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-end gap-2 self-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type ConfirmationActionProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ConfirmationAction = (props: ConfirmationActionProps) => (
|
||||
<Button className="h-8 px-3 text-sm" type="button" {...props} />
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ConnectionLineComponent } from "@xyflow/react";
|
||||
|
||||
const HALF = 0.5;
|
||||
|
||||
export const Connection: ConnectionLineComponent = ({
|
||||
fromX,
|
||||
fromY,
|
||||
toX,
|
||||
toY,
|
||||
}) => (
|
||||
<g>
|
||||
<path
|
||||
className="animated"
|
||||
d={`M${fromX},${fromY} C ${fromX + (toX - fromX) * HALF},${fromY} ${fromX + (toX - fromX) * HALF},${toY} ${toX},${toY}`}
|
||||
fill="none"
|
||||
stroke="var(--color-ring)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<circle
|
||||
cx={toX}
|
||||
cy={toY}
|
||||
fill="#fff"
|
||||
r={3}
|
||||
stroke="var(--color-ring)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
@@ -0,0 +1,409 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LanguageModelUsage } from "ai";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { getUsage } from "tokenlens";
|
||||
|
||||
const PERCENT_MAX = 100;
|
||||
const ICON_RADIUS = 10;
|
||||
const ICON_VIEWBOX = 24;
|
||||
const ICON_CENTER = 12;
|
||||
const ICON_STROKE_WIDTH = 2;
|
||||
|
||||
type ModelId = string;
|
||||
|
||||
interface ContextSchema {
|
||||
usedTokens: number;
|
||||
maxTokens: number;
|
||||
usage?: LanguageModelUsage;
|
||||
modelId?: ModelId;
|
||||
}
|
||||
|
||||
const ContextContext = createContext<ContextSchema | null>(null);
|
||||
|
||||
const useContextValue = () => {
|
||||
const context = useContext(ContextContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Context components must be used within Context");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ContextProps = ComponentProps<typeof HoverCard> & ContextSchema;
|
||||
|
||||
export const Context = ({
|
||||
usedTokens,
|
||||
maxTokens,
|
||||
usage,
|
||||
modelId,
|
||||
...props
|
||||
}: ContextProps) => {
|
||||
const contextValue = useMemo(
|
||||
() => ({ maxTokens, modelId, usage, usedTokens }),
|
||||
[maxTokens, modelId, usage, usedTokens]
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextContext.Provider value={contextValue}>
|
||||
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
||||
</ContextContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const ContextIcon = () => {
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
const circumference = 2 * Math.PI * ICON_RADIUS;
|
||||
const usedPercent = usedTokens / maxTokens;
|
||||
const dashOffset = circumference * (1 - usedPercent);
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-label="Model context usage"
|
||||
height="20"
|
||||
role="img"
|
||||
style={{ color: "currentcolor" }}
|
||||
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
|
||||
width="20"
|
||||
>
|
||||
<circle
|
||||
cx={ICON_CENTER}
|
||||
cy={ICON_CENTER}
|
||||
fill="none"
|
||||
opacity="0.25"
|
||||
r={ICON_RADIUS}
|
||||
stroke="currentColor"
|
||||
strokeWidth={ICON_STROKE_WIDTH}
|
||||
/>
|
||||
<circle
|
||||
cx={ICON_CENTER}
|
||||
cy={ICON_CENTER}
|
||||
fill="none"
|
||||
opacity="0.7"
|
||||
r={ICON_RADIUS}
|
||||
stroke="currentColor"
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
strokeDashoffset={dashOffset}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={ICON_STROKE_WIDTH}
|
||||
style={{ transform: "rotate(-90deg)", transformOrigin: "center" }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextTriggerProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
const usedPercent = usedTokens / maxTokens;
|
||||
const renderedPercent = new Intl.NumberFormat("en-US", {
|
||||
maximumFractionDigits: 1,
|
||||
style: "percent",
|
||||
}).format(usedPercent);
|
||||
|
||||
return (
|
||||
<HoverCardTrigger>
|
||||
{children ?? (
|
||||
<Button type="button" variant="ghost" {...props}>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{renderedPercent}
|
||||
</span>
|
||||
<ContextIcon />
|
||||
</Button>
|
||||
)}
|
||||
</HoverCardTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextContentProps = ComponentProps<typeof HoverCardContent>;
|
||||
|
||||
export const ContextContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ContextContentProps) => (
|
||||
<HoverCardContent
|
||||
className={cn("min-w-60 divide-y overflow-hidden p-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ContextContentHeaderProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextContentHeader = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentHeaderProps) => {
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
const usedPercent = usedTokens / maxTokens;
|
||||
const displayPct = new Intl.NumberFormat("en-US", {
|
||||
maximumFractionDigits: 1,
|
||||
style: "percent",
|
||||
}).format(usedPercent);
|
||||
const used = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
}).format(usedTokens);
|
||||
const total = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
}).format(maxTokens);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full space-y-2 p-3", className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<p>{displayPct}</p>
|
||||
<p className="font-mono text-muted-foreground">
|
||||
{used} / {total}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Progress className="bg-muted" value={usedPercent * PERCENT_MAX} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextContentBodyProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextContentBody = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentBodyProps) => (
|
||||
<div className={cn("w-full p-3", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ContextContentFooterProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextContentFooter = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentFooterProps) => {
|
||||
const { modelId, usage } = useContextValue();
|
||||
const costUSD = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: {
|
||||
input: usage?.inputTokens ?? 0,
|
||||
output: usage?.outputTokens ?? 0,
|
||||
},
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const totalCost = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
style: "currency",
|
||||
}).format(costUSD ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-3 bg-secondary p-3 text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<span className="text-muted-foreground">Total cost</span>
|
||||
<span>{totalCost}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TokensWithCost = ({
|
||||
tokens,
|
||||
costText,
|
||||
}: {
|
||||
tokens?: number;
|
||||
costText?: string;
|
||||
}) => (
|
||||
<span>
|
||||
{tokens === undefined
|
||||
? "—"
|
||||
: new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
}).format(tokens)}
|
||||
{costText ? (
|
||||
<span className="ml-2 text-muted-foreground">• {costText}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type ContextInputUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextInputUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextInputUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const inputTokens = usage?.inputTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!inputTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inputCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { input: inputTokens, output: 0 },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const inputCostText = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
style: "currency",
|
||||
}).format(inputCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Input</span>
|
||||
<TokensWithCost costText={inputCostText} tokens={inputTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextOutputUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextOutputUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextOutputUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const outputTokens = usage?.outputTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!outputTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const outputCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { input: 0, output: outputTokens },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const outputCostText = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
style: "currency",
|
||||
}).format(outputCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Output</span>
|
||||
<TokensWithCost costText={outputCostText} tokens={outputTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextReasoningUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextReasoningUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextReasoningUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const reasoningTokens = usage?.reasoningTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!reasoningTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reasoningCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { reasoningTokens },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const reasoningCostText = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
style: "currency",
|
||||
}).format(reasoningCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Reasoning</span>
|
||||
<TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextCacheUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextCacheUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextCacheUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const cacheTokens = usage?.cachedInputTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!cacheTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { cacheReads: cacheTokens, input: 0, output: 0 },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const cacheCostText = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
style: "currency",
|
||||
}).format(cacheCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Cache</span>
|
||||
<TokensWithCost costText={cacheCostText} tokens={cacheTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Controls as ControlsPrimitive } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type ControlsProps = ComponentProps<typeof ControlsPrimitive>;
|
||||
|
||||
export const Controls = ({ className, ...props }: ControlsProps) => (
|
||||
<ControlsPrimitive
|
||||
className={cn(
|
||||
"gap-px overflow-hidden rounded-md border bg-card p-1 shadow-none!",
|
||||
"[&>button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent! [&>button]:hover:bg-secondary!",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UIMessage } from "ai";
|
||||
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn("relative flex-1 overflow-y-hidden", className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationContentProps = ComponentProps<
|
||||
typeof StickToBottom.Content
|
||||
>;
|
||||
|
||||
export const ConversationContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationContentProps) => (
|
||||
<StickToBottom.Content
|
||||
className={cn("flex flex-col gap-8 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
className,
|
||||
title = "No messages yet",
|
||||
description = "Start a conversation to see messages here",
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationScrollButtonProps) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
scrollToBottom();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
return (
|
||||
!isAtBottom && (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
|
||||
className
|
||||
)}
|
||||
onClick={handleScrollToBottom}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const getMessageText = (message: UIMessage): string =>
|
||||
message.parts
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("");
|
||||
|
||||
export type ConversationDownloadProps = Omit<
|
||||
ComponentProps<typeof Button>,
|
||||
"onClick"
|
||||
> & {
|
||||
messages: UIMessage[];
|
||||
filename?: string;
|
||||
formatMessage?: (message: UIMessage, index: number) => string;
|
||||
};
|
||||
|
||||
const defaultFormatMessage = (message: UIMessage): string => {
|
||||
const roleLabel =
|
||||
message.role.charAt(0).toUpperCase() + message.role.slice(1);
|
||||
return `**${roleLabel}:** ${getMessageText(message)}`;
|
||||
};
|
||||
|
||||
export const messagesToMarkdown = (
|
||||
messages: UIMessage[],
|
||||
formatMessage: (
|
||||
message: UIMessage,
|
||||
index: number
|
||||
) => string = defaultFormatMessage
|
||||
): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");
|
||||
|
||||
export const ConversationDownload = ({
|
||||
messages,
|
||||
filename = "conversation.md",
|
||||
formatMessage = defaultFormatMessage,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ConversationDownloadProps) => {
|
||||
const handleDownload = useCallback(() => {
|
||||
const markdown = messagesToMarkdown(messages, formatMessage);
|
||||
const blob = new Blob([markdown], { type: "text/markdown" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [messages, filename, formatMessage]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute top-4 right-4 rounded-full dark:bg-background dark:hover:bg-muted",
|
||||
className
|
||||
)}
|
||||
onClick={handleDownload}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <DownloadIcon className="size-4" />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { EdgeProps, InternalNode, Node } from "@xyflow/react";
|
||||
import {
|
||||
BaseEdge,
|
||||
getBezierPath,
|
||||
getSimpleBezierPath,
|
||||
Position,
|
||||
useInternalNode,
|
||||
} from "@xyflow/react";
|
||||
|
||||
const Temporary = ({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
}: EdgeProps) => {
|
||||
const [edgePath] = getSimpleBezierPath({
|
||||
sourcePosition,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetPosition,
|
||||
targetX,
|
||||
targetY,
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseEdge
|
||||
className="stroke-1 stroke-ring"
|
||||
id={id}
|
||||
path={edgePath}
|
||||
style={{
|
||||
strokeDasharray: "5, 5",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getHandleCoordsByPosition = (
|
||||
node: InternalNode<Node>,
|
||||
handlePosition: Position
|
||||
) => {
|
||||
// Choose the handle type based on position - Left is for target, Right is for source
|
||||
const handleType = handlePosition === Position.Left ? "target" : "source";
|
||||
|
||||
const handle = node.internals.handleBounds?.[handleType]?.find(
|
||||
(h) => h.position === handlePosition
|
||||
);
|
||||
|
||||
if (!handle) {
|
||||
return [0, 0] as const;
|
||||
}
|
||||
|
||||
let offsetX = handle.width / 2;
|
||||
let offsetY = handle.height / 2;
|
||||
|
||||
// this is a tiny detail to make the markerEnd of an edge visible.
|
||||
// The handle position that gets calculated has the origin top-left, so depending which side we are using, we add a little offset
|
||||
// when the handlePosition is Position.Right for example, we need to add an offset as big as the handle itself in order to get the correct position
|
||||
switch (handlePosition) {
|
||||
case Position.Left: {
|
||||
offsetX = 0;
|
||||
break;
|
||||
}
|
||||
case Position.Right: {
|
||||
offsetX = handle.width;
|
||||
break;
|
||||
}
|
||||
case Position.Top: {
|
||||
offsetY = 0;
|
||||
break;
|
||||
}
|
||||
case Position.Bottom: {
|
||||
offsetY = handle.height;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Invalid handle position: ${handlePosition}`);
|
||||
}
|
||||
}
|
||||
|
||||
const x = node.internals.positionAbsolute.x + handle.x + offsetX;
|
||||
const y = node.internals.positionAbsolute.y + handle.y + offsetY;
|
||||
|
||||
return [x, y] as const;
|
||||
};
|
||||
|
||||
const getEdgeParams = (
|
||||
source: InternalNode<Node>,
|
||||
target: InternalNode<Node>
|
||||
) => {
|
||||
const sourcePos = Position.Right;
|
||||
const [sx, sy] = getHandleCoordsByPosition(source, sourcePos);
|
||||
const targetPos = Position.Left;
|
||||
const [tx, ty] = getHandleCoordsByPosition(target, targetPos);
|
||||
|
||||
return {
|
||||
sourcePos,
|
||||
sx,
|
||||
sy,
|
||||
targetPos,
|
||||
tx,
|
||||
ty,
|
||||
};
|
||||
};
|
||||
|
||||
const Animated = ({ id, source, target, markerEnd, style }: EdgeProps) => {
|
||||
const sourceNode = useInternalNode(source);
|
||||
const targetNode = useInternalNode(target);
|
||||
|
||||
if (!(sourceNode && targetNode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(
|
||||
sourceNode,
|
||||
targetNode
|
||||
);
|
||||
|
||||
const [edgePath] = getBezierPath({
|
||||
sourcePosition: sourcePos,
|
||||
sourceX: sx,
|
||||
sourceY: sy,
|
||||
targetPosition: targetPos,
|
||||
targetX: tx,
|
||||
targetY: ty,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge id={id} markerEnd={markerEnd} path={edgePath} style={style} />
|
||||
<circle fill="var(--primary)" r="4">
|
||||
<animateMotion dur="2s" path={edgePath} repeatCount="indefinite" />
|
||||
</circle>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Edge = {
|
||||
Animated,
|
||||
Temporary,
|
||||
};
|
||||
@@ -0,0 +1,324 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, CopyIcon, EyeIcon, EyeOffIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface EnvironmentVariablesContextType {
|
||||
showValues: boolean;
|
||||
setShowValues: (show: boolean) => void;
|
||||
}
|
||||
|
||||
// Default noop for context default value
|
||||
// oxlint-disable-next-line eslint(no-empty-function)
|
||||
const noop = () => {};
|
||||
|
||||
const EnvironmentVariablesContext =
|
||||
createContext<EnvironmentVariablesContextType>({
|
||||
setShowValues: noop,
|
||||
showValues: false,
|
||||
});
|
||||
|
||||
export type EnvironmentVariablesProps = HTMLAttributes<HTMLDivElement> & {
|
||||
showValues?: boolean;
|
||||
defaultShowValues?: boolean;
|
||||
onShowValuesChange?: (show: boolean) => void;
|
||||
};
|
||||
|
||||
export const EnvironmentVariables = ({
|
||||
showValues: controlledShowValues,
|
||||
defaultShowValues = false,
|
||||
onShowValuesChange,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariablesProps) => {
|
||||
const [internalShowValues, setInternalShowValues] =
|
||||
useState(defaultShowValues);
|
||||
const showValues = controlledShowValues ?? internalShowValues;
|
||||
|
||||
const setShowValues = useCallback(
|
||||
(show: boolean) => {
|
||||
setInternalShowValues(show);
|
||||
onShowValuesChange?.(show);
|
||||
},
|
||||
[onShowValuesChange]
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ setShowValues, showValues }),
|
||||
[setShowValues, showValues]
|
||||
);
|
||||
|
||||
return (
|
||||
<EnvironmentVariablesContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("rounded-lg border bg-background", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</EnvironmentVariablesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type EnvironmentVariablesHeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const EnvironmentVariablesHeader = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariablesHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b px-4 py-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type EnvironmentVariablesTitleProps = HTMLAttributes<HTMLHeadingElement>;
|
||||
|
||||
export const EnvironmentVariablesTitle = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariablesTitleProps) => (
|
||||
<h3 className={cn("font-medium text-sm", className)} {...props}>
|
||||
{children ?? "Environment Variables"}
|
||||
</h3>
|
||||
);
|
||||
|
||||
export type EnvironmentVariablesToggleProps = ComponentProps<typeof Switch>;
|
||||
|
||||
export const EnvironmentVariablesToggle = ({
|
||||
className,
|
||||
...props
|
||||
}: EnvironmentVariablesToggleProps) => {
|
||||
const { showValues, setShowValues } = useContext(EnvironmentVariablesContext);
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{showValues ? <EyeIcon size={14} /> : <EyeOffIcon size={14} />}
|
||||
</span>
|
||||
<Switch
|
||||
aria-label="Toggle value visibility"
|
||||
checked={showValues}
|
||||
onCheckedChange={setShowValues}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type EnvironmentVariablesContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const EnvironmentVariablesContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariablesContentProps) => (
|
||||
<div className={cn("divide-y", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface EnvironmentVariableContextType {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const EnvironmentVariableContext =
|
||||
createContext<EnvironmentVariableContextType>({
|
||||
name: "",
|
||||
value: "",
|
||||
});
|
||||
|
||||
export type EnvironmentVariableGroupProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const EnvironmentVariableGroup = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariableGroupProps) => (
|
||||
<div className={cn("flex items-center gap-2", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type EnvironmentVariableNameProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const EnvironmentVariableName = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariableNameProps) => {
|
||||
const { name } = useContext(EnvironmentVariableContext);
|
||||
|
||||
return (
|
||||
<span className={cn("font-mono text-sm", className)} {...props}>
|
||||
{children ?? name}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type EnvironmentVariableValueProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const EnvironmentVariableValue = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariableValueProps) => {
|
||||
const { value } = useContext(EnvironmentVariableContext);
|
||||
const { showValues } = useContext(EnvironmentVariablesContext);
|
||||
|
||||
const displayValue = showValues
|
||||
? value
|
||||
: "•".repeat(Math.min(value.length, 20));
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-muted-foreground text-sm",
|
||||
!showValues && "select-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? displayValue}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type EnvironmentVariableProps = HTMLAttributes<HTMLDivElement> & {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const EnvironmentVariable = ({
|
||||
name,
|
||||
value,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariableProps) => {
|
||||
const envVarContextValue = useMemo(() => ({ name, value }), [name, value]);
|
||||
|
||||
return (
|
||||
<EnvironmentVariableContext.Provider value={envVarContextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-4 px-4 py-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<EnvironmentVariableName />
|
||||
</div>
|
||||
<EnvironmentVariableValue />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</EnvironmentVariableContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type EnvironmentVariableCopyButtonProps = ComponentProps<
|
||||
typeof Button
|
||||
> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
copyFormat?: "name" | "value" | "export";
|
||||
};
|
||||
|
||||
export const EnvironmentVariableCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
copyFormat = "value",
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: EnvironmentVariableCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<number>(0);
|
||||
const { name, value } = useContext(EnvironmentVariableContext);
|
||||
|
||||
const getTextToCopy = useCallback((): string => {
|
||||
const formatMap = {
|
||||
export: () => `export ${name}="${value}"`,
|
||||
name: () => name,
|
||||
value: () => value,
|
||||
};
|
||||
return formatMap[copyFormat]();
|
||||
}, [name, value, copyFormat]);
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(getTextToCopy());
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}, [getTextToCopy, onCopy, onError, timeout]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("size-6 shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={12} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type EnvironmentVariableRequiredProps = ComponentProps<typeof Badge>;
|
||||
|
||||
export const EnvironmentVariableRequired = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariableRequiredProps) => (
|
||||
<Badge className={cn("text-xs", className)} variant="secondary" {...props}>
|
||||
{children ?? "Required"}
|
||||
</Badge>
|
||||
);
|
||||
@@ -0,0 +1,297 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
FileIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
} from "lucide-react";
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface FileTreeContextType {
|
||||
expandedPaths: Set<string>;
|
||||
togglePath: (path: string) => void;
|
||||
selectedPath?: string;
|
||||
onSelect?: (path: string) => void;
|
||||
}
|
||||
|
||||
// Default noop for context default value
|
||||
// oxlint-disable-next-line eslint(no-empty-function)
|
||||
const noop = () => {};
|
||||
|
||||
const FileTreeContext = createContext<FileTreeContextType>({
|
||||
// oxlint-disable-next-line eslint-plugin-unicorn(no-new-builtin)
|
||||
expandedPaths: new Set(),
|
||||
togglePath: noop,
|
||||
});
|
||||
|
||||
export type FileTreeProps = Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> & {
|
||||
expanded?: Set<string>;
|
||||
defaultExpanded?: Set<string>;
|
||||
selectedPath?: string;
|
||||
onSelect?: (path: string) => void;
|
||||
onExpandedChange?: (expanded: Set<string>) => void;
|
||||
};
|
||||
|
||||
export const FileTree = ({
|
||||
expanded: controlledExpanded,
|
||||
defaultExpanded = new Set(),
|
||||
selectedPath,
|
||||
onSelect,
|
||||
onExpandedChange,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeProps) => {
|
||||
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
|
||||
const expandedPaths = controlledExpanded ?? internalExpanded;
|
||||
|
||||
const togglePath = useCallback(
|
||||
(path: string) => {
|
||||
const newExpanded = new Set(expandedPaths);
|
||||
if (newExpanded.has(path)) {
|
||||
newExpanded.delete(path);
|
||||
} else {
|
||||
newExpanded.add(path);
|
||||
}
|
||||
setInternalExpanded(newExpanded);
|
||||
onExpandedChange?.(newExpanded);
|
||||
},
|
||||
[expandedPaths, onExpandedChange]
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ expandedPaths, onSelect, selectedPath, togglePath }),
|
||||
[expandedPaths, onSelect, selectedPath, togglePath]
|
||||
);
|
||||
|
||||
return (
|
||||
<FileTreeContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-background font-mono text-sm",
|
||||
className
|
||||
)}
|
||||
role="tree"
|
||||
{...props}
|
||||
>
|
||||
<div className="p-2">{children}</div>
|
||||
</div>
|
||||
</FileTreeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type FileTreeIconProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const FileTreeIcon = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeIconProps) => (
|
||||
<span className={cn("shrink-0", className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type FileTreeNameProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const FileTreeName = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeNameProps) => (
|
||||
<span className={cn("truncate", className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
interface FileTreeFolderContextType {
|
||||
path: string;
|
||||
name: string;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
const FileTreeFolderContext = createContext<FileTreeFolderContextType>({
|
||||
isExpanded: false,
|
||||
name: "",
|
||||
path: "",
|
||||
});
|
||||
|
||||
export type FileTreeFolderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
path: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const FileTreeFolder = ({
|
||||
path,
|
||||
name,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeFolderProps) => {
|
||||
const { expandedPaths, togglePath, selectedPath, onSelect } =
|
||||
useContext(FileTreeContext);
|
||||
const isExpanded = expandedPaths.has(path);
|
||||
const isSelected = selectedPath === path;
|
||||
|
||||
const handleOpenChange = useCallback(() => {
|
||||
togglePath(path);
|
||||
}, [togglePath, path]);
|
||||
|
||||
const handleSelect = useCallback(() => {
|
||||
onSelect?.(path);
|
||||
}, [onSelect, path]);
|
||||
|
||||
const folderContextValue = useMemo(
|
||||
() => ({ isExpanded, name, path }),
|
||||
[isExpanded, name, path]
|
||||
);
|
||||
|
||||
return (
|
||||
<FileTreeFolderContext.Provider value={folderContextValue}>
|
||||
<Collapsible onOpenChange={handleOpenChange} open={isExpanded}>
|
||||
<div
|
||||
className={cn("", className)}
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1 rounded px-2 py-1 text-left transition-colors hover:bg-muted/50",
|
||||
isSelected && "bg-muted"
|
||||
)}
|
||||
>
|
||||
<CollapsibleTrigger render={<button className="flex shrink-0 cursor-pointer items-center border-none bg-transparent p-0" type="button" />}><ChevronRightIcon
|
||||
className={cn(
|
||||
"size-4 shrink-0 text-muted-foreground transition-transform",
|
||||
isExpanded && "rotate-90"
|
||||
)}
|
||||
/></CollapsibleTrigger>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left"
|
||||
onClick={handleSelect}
|
||||
type="button"
|
||||
>
|
||||
<FileTreeIcon>
|
||||
{isExpanded ? (
|
||||
<FolderOpenIcon className="size-4 text-blue-500" />
|
||||
) : (
|
||||
<FolderIcon className="size-4 text-blue-500" />
|
||||
)}
|
||||
</FileTreeIcon>
|
||||
<FileTreeName>{name}</FileTreeName>
|
||||
</button>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<div className="ml-4 border-l pl-2">{children}</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</FileTreeFolderContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
interface FileTreeFileContextType {
|
||||
path: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const FileTreeFileContext = createContext<FileTreeFileContextType>({
|
||||
name: "",
|
||||
path: "",
|
||||
});
|
||||
|
||||
export type FileTreeFileProps = HTMLAttributes<HTMLDivElement> & {
|
||||
path: string;
|
||||
name: string;
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
export const FileTreeFile = ({
|
||||
path,
|
||||
name,
|
||||
icon,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeFileProps) => {
|
||||
const { selectedPath, onSelect } = useContext(FileTreeContext);
|
||||
const isSelected = selectedPath === path;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect?.(path);
|
||||
}, [onSelect, path]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
onSelect?.(path);
|
||||
}
|
||||
},
|
||||
[onSelect, path]
|
||||
);
|
||||
|
||||
const fileContextValue = useMemo(() => ({ name, path }), [name, path]);
|
||||
|
||||
return (
|
||||
<FileTreeFileContext.Provider value={fileContextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-1 rounded px-2 py-1 transition-colors hover:bg-muted/50",
|
||||
isSelected && "bg-muted",
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{/* Spacer for alignment */}
|
||||
<span className="size-4 shrink-0" />
|
||||
<FileTreeIcon>
|
||||
{icon ?? <FileIcon className="size-4 text-muted-foreground" />}
|
||||
</FileTreeIcon>
|
||||
<FileTreeName>{name}</FileTreeName>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</FileTreeFileContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type FileTreeActionsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
const stopPropagation = (e: React.SyntheticEvent) => e.stopPropagation();
|
||||
|
||||
export const FileTreeActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeActionsProps) => (
|
||||
<div
|
||||
className={cn("ml-auto flex items-center gap-1", className)}
|
||||
onClick={stopPropagation}
|
||||
onKeyDown={stopPropagation}
|
||||
role="group"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Experimental_GeneratedImage } from "ai";
|
||||
|
||||
export type ImageProps = Experimental_GeneratedImage & {
|
||||
className?: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
export const Image = ({
|
||||
base64,
|
||||
uint8Array: _uint8Array,
|
||||
mediaType,
|
||||
...props
|
||||
}: ImageProps) => (
|
||||
<img
|
||||
{...props}
|
||||
alt={props.alt}
|
||||
className={cn(
|
||||
"h-auto max-w-full overflow-hidden rounded-md",
|
||||
props.className
|
||||
)}
|
||||
src={`data:${mediaType};base64,${base64}`}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { CarouselApi } from "@/components/ui/carousel";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from "@/components/ui/carousel";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export type InlineCitationProps = ComponentProps<"span">;
|
||||
|
||||
export const InlineCitation = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationProps) => (
|
||||
<span
|
||||
className={cn("group inline items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationTextProps = ComponentProps<"span">;
|
||||
|
||||
export const InlineCitationText = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationTextProps) => (
|
||||
<span
|
||||
className={cn("transition-colors group-hover:bg-accent", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
|
||||
|
||||
export const InlineCitationCard = (props: InlineCitationCardProps) => (
|
||||
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
||||
);
|
||||
|
||||
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
|
||||
sources: string[];
|
||||
};
|
||||
|
||||
export const InlineCitationCardTrigger = ({
|
||||
sources,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCardTriggerProps) => (
|
||||
<HoverCardTrigger render={<Badge className={cn("ml-1 rounded-full", className)} variant="secondary" {...props} />}>{sources[0] ? (
|
||||
<>
|
||||
{new URL(sources[0]).hostname}{" "}
|
||||
{sources.length > 1 && `+${sources.length - 1}`}
|
||||
</>
|
||||
) : (
|
||||
"unknown"
|
||||
)}</HoverCardTrigger>
|
||||
);
|
||||
|
||||
export type InlineCitationCardBodyProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCardBody = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCardBodyProps) => (
|
||||
<HoverCardContent className={cn("relative w-80 p-0", className)} {...props} />
|
||||
);
|
||||
|
||||
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined);
|
||||
|
||||
const useCarouselApi = () => {
|
||||
const context = useContext(CarouselApiContext);
|
||||
return context;
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselProps = ComponentProps<typeof Carousel>;
|
||||
|
||||
export const InlineCitationCarousel = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: InlineCitationCarouselProps) => {
|
||||
const [api, setApi] = useState<CarouselApi>();
|
||||
|
||||
return (
|
||||
<CarouselApiContext.Provider value={api}>
|
||||
<Carousel className={cn("w-full", className)} setApi={setApi} {...props}>
|
||||
{children}
|
||||
</Carousel>
|
||||
</CarouselApiContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselContentProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCarouselContent = (
|
||||
props: InlineCitationCarouselContentProps
|
||||
) => <CarouselContent {...props} />;
|
||||
|
||||
export type InlineCitationCarouselItemProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCarouselItem = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselItemProps) => (
|
||||
<CarouselItem
|
||||
className={cn("w-full space-y-2 p-4 pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationCarouselHeaderProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCarouselHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationCarouselIndexProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCarouselIndex = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselIndexProps) => {
|
||||
const api = useCarouselApi();
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
const syncState = useCallback(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
setCount(api.scrollSnapList().length);
|
||||
setCurrent(api.selectedScrollSnap() + 1);
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncState();
|
||||
|
||||
api.on("select", syncState);
|
||||
|
||||
return () => {
|
||||
api.off("select", syncState);
|
||||
};
|
||||
}, [api, syncState]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? `${current}/${count}`}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselPrevProps = ComponentProps<"button">;
|
||||
|
||||
export const InlineCitationCarouselPrev = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselPrevProps) => {
|
||||
const api = useCarouselApi();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (api) {
|
||||
api.scrollPrev();
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Previous"
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselNextProps = ComponentProps<"button">;
|
||||
|
||||
export const InlineCitationCarouselNext = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselNextProps) => {
|
||||
const api = useCarouselApi();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (api) {
|
||||
api.scrollNext();
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Next"
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<ArrowRightIcon className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationSourceProps = ComponentProps<"div"> & {
|
||||
title?: string;
|
||||
url?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export const InlineCitationSource = ({
|
||||
title,
|
||||
url,
|
||||
description,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: InlineCitationSourceProps) => (
|
||||
<div className={cn("space-y-1", className)} {...props}>
|
||||
{title && (
|
||||
<h4 className="truncate font-medium text-sm leading-tight">{title}</h4>
|
||||
)}
|
||||
{url && (
|
||||
<p className="truncate break-all text-muted-foreground text-xs">{url}</p>
|
||||
)}
|
||||
{description && (
|
||||
<p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type InlineCitationQuoteProps = ComponentProps<"blockquote">;
|
||||
|
||||
export const InlineCitationQuote = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationQuoteProps) => (
|
||||
<blockquote
|
||||
className={cn(
|
||||
"border-muted border-l-2 pl-3 text-muted-foreground text-sm italic",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
@@ -0,0 +1,310 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { TProps as JsxParserProps } from "react-jsx-parser";
|
||||
import JsxParser from "react-jsx-parser";
|
||||
|
||||
interface JSXPreviewContextValue {
|
||||
jsx: string;
|
||||
processedJsx: string;
|
||||
isStreaming: boolean;
|
||||
error: Error | null;
|
||||
setError: (error: Error | null) => void;
|
||||
setLastGoodJsx: (jsx: string) => void;
|
||||
components: JsxParserProps["components"];
|
||||
bindings: JsxParserProps["bindings"];
|
||||
onErrorProp?: (error: Error) => void;
|
||||
}
|
||||
|
||||
const JSXPreviewContext = createContext<JSXPreviewContextValue | null>(null);
|
||||
|
||||
const TAG_REGEX = /<\/?([a-zA-Z][a-zA-Z0-9]*)\s*([^>]*?)(\/)?>/;
|
||||
|
||||
export const useJSXPreview = () => {
|
||||
const context = useContext(JSXPreviewContext);
|
||||
if (!context) {
|
||||
throw new Error("JSXPreview components must be used within JSXPreview");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const matchJsxTag = (code: string) => {
|
||||
if (code.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = code.match(TAG_REGEX);
|
||||
|
||||
if (!match || match.index === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [fullMatch, tagName, attributes, selfClosing] = match;
|
||||
|
||||
let type: "self-closing" | "closing" | "opening";
|
||||
if (selfClosing) {
|
||||
type = "self-closing";
|
||||
} else if (fullMatch.startsWith("</")) {
|
||||
type = "closing";
|
||||
} else {
|
||||
type = "opening";
|
||||
}
|
||||
|
||||
return {
|
||||
attributes: attributes.trim(),
|
||||
endIndex: match.index + fullMatch.length,
|
||||
startIndex: match.index,
|
||||
tag: fullMatch,
|
||||
tagName,
|
||||
type,
|
||||
};
|
||||
};
|
||||
|
||||
const stripIncompleteTag = (text: string) => {
|
||||
// Find the last '<' that isn't part of a complete tag
|
||||
const lastOpen = text.lastIndexOf("<");
|
||||
if (lastOpen === -1) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const afterOpen = text.slice(lastOpen);
|
||||
// If there's no closing '>' after the last '<', it's an incomplete tag
|
||||
if (!afterOpen.includes(">")) {
|
||||
return text.slice(0, lastOpen);
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
const completeJsxTag = (code: string) => {
|
||||
const stack: string[] = [];
|
||||
let result = "";
|
||||
let currentPosition = 0;
|
||||
|
||||
while (currentPosition < code.length) {
|
||||
const match = matchJsxTag(code.slice(currentPosition));
|
||||
if (!match) {
|
||||
// No more tags found, strip any trailing incomplete tag
|
||||
result += stripIncompleteTag(code.slice(currentPosition));
|
||||
break;
|
||||
}
|
||||
const { tagName, type, endIndex } = match;
|
||||
|
||||
// Include any text content before this tag
|
||||
result += code.slice(currentPosition, currentPosition + endIndex);
|
||||
|
||||
if (type === "opening") {
|
||||
stack.push(tagName);
|
||||
} else if (type === "closing") {
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
currentPosition += endIndex;
|
||||
}
|
||||
|
||||
return (
|
||||
result +
|
||||
stack
|
||||
.toReversed()
|
||||
.map((tag) => `</${tag}>`)
|
||||
.join("")
|
||||
);
|
||||
};
|
||||
|
||||
export type JSXPreviewProps = ComponentProps<"div"> & {
|
||||
jsx: string;
|
||||
isStreaming?: boolean;
|
||||
components?: JsxParserProps["components"];
|
||||
bindings?: JsxParserProps["bindings"];
|
||||
onError?: (error: Error) => void;
|
||||
};
|
||||
|
||||
export const JSXPreview = memo(
|
||||
({
|
||||
jsx,
|
||||
isStreaming = false,
|
||||
components,
|
||||
bindings,
|
||||
onError,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: JSXPreviewProps) => {
|
||||
const [prevJsx, setPrevJsx] = useState(jsx);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [_lastGoodJsx, setLastGoodJsx] = useState("");
|
||||
|
||||
// Clear error when jsx changes (derived state pattern)
|
||||
if (jsx !== prevJsx) {
|
||||
setPrevJsx(jsx);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const processedJsx = useMemo(
|
||||
() => (isStreaming ? completeJsxTag(jsx) : jsx),
|
||||
[jsx, isStreaming]
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
bindings,
|
||||
components,
|
||||
error,
|
||||
isStreaming,
|
||||
jsx,
|
||||
onErrorProp: onError,
|
||||
processedJsx,
|
||||
setError,
|
||||
setLastGoodJsx,
|
||||
}),
|
||||
[
|
||||
bindings,
|
||||
components,
|
||||
error,
|
||||
isStreaming,
|
||||
jsx,
|
||||
onError,
|
||||
processedJsx,
|
||||
setError,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<JSXPreviewContext.Provider value={contextValue}>
|
||||
<div className={cn("relative", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</JSXPreviewContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
JSXPreview.displayName = "JSXPreview";
|
||||
|
||||
export type JSXPreviewContentProps = Omit<ComponentProps<"div">, "children">;
|
||||
|
||||
export const JSXPreviewContent = memo(
|
||||
({ className, ...props }: JSXPreviewContentProps) => {
|
||||
const {
|
||||
processedJsx,
|
||||
isStreaming,
|
||||
components,
|
||||
bindings,
|
||||
setError,
|
||||
setLastGoodJsx,
|
||||
onErrorProp,
|
||||
} = useJSXPreview();
|
||||
const errorReportedRef = useRef<string | null>(null);
|
||||
const lastGoodJsxRef = useRef("");
|
||||
const [hadError, setHadError] = useState(false);
|
||||
|
||||
// Reset error tracking when jsx changes
|
||||
useEffect(() => {
|
||||
errorReportedRef.current = null;
|
||||
setHadError(false);
|
||||
}, [processedJsx]);
|
||||
|
||||
const handleError = useCallback(
|
||||
(err: Error) => {
|
||||
// Prevent duplicate error reports for the same jsx
|
||||
if (errorReportedRef.current === processedJsx) {
|
||||
return;
|
||||
}
|
||||
errorReportedRef.current = processedJsx;
|
||||
|
||||
// During streaming, suppress errors and fall back to last good JSX
|
||||
if (isStreaming) {
|
||||
setHadError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err);
|
||||
onErrorProp?.(err);
|
||||
},
|
||||
[processedJsx, isStreaming, onErrorProp, setError]
|
||||
);
|
||||
|
||||
// Track the last JSX that rendered without error
|
||||
useEffect(() => {
|
||||
if (!errorReportedRef.current) {
|
||||
lastGoodJsxRef.current = processedJsx;
|
||||
setLastGoodJsx(processedJsx);
|
||||
}
|
||||
}, [processedJsx, setLastGoodJsx]);
|
||||
|
||||
// During streaming, if the current JSX errored, re-render with last good version
|
||||
const displayJsx =
|
||||
isStreaming && hadError ? lastGoodJsxRef.current : processedJsx;
|
||||
|
||||
return (
|
||||
<div className={cn("jsx-preview-content", className)} {...props}>
|
||||
<JsxParser
|
||||
bindings={bindings}
|
||||
components={components}
|
||||
jsx={displayJsx}
|
||||
onError={handleError}
|
||||
renderInWrapper={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
JSXPreviewContent.displayName = "JSXPreviewContent";
|
||||
|
||||
export type JSXPreviewErrorProps = ComponentProps<"div"> & {
|
||||
children?: ReactNode | ((error: Error) => ReactNode);
|
||||
};
|
||||
|
||||
const renderChildren = (
|
||||
children: ReactNode | ((error: Error) => ReactNode),
|
||||
error: Error
|
||||
): ReactNode => {
|
||||
if (typeof children === "function") {
|
||||
return children(error);
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
export const JSXPreviewError = memo(
|
||||
({ className, children, ...props }: JSXPreviewErrorProps) => {
|
||||
const { error } = useJSXPreview();
|
||||
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ? (
|
||||
renderChildren(children, error)
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="size-4 shrink-0" />
|
||||
<span>{error.message}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
JSXPreviewError.displayName = "JSXPreviewError";
|
||||
@@ -0,0 +1,360 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ButtonGroup,
|
||||
ButtonGroupText,
|
||||
} from "@/components/ui/button-group";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cjk } from "@streamdown/cjk";
|
||||
import { code } from "@streamdown/code";
|
||||
import { math } from "@streamdown/math";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import type { UIMessage } from "ai";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex w-full max-w-[95%] flex-col gap-2",
|
||||
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
|
||||
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
|
||||
"group-[.is-assistant]:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const MessageAction = ({
|
||||
tooltip,
|
||||
children,
|
||||
label,
|
||||
variant = "ghost",
|
||||
size = "icon-sm",
|
||||
...props
|
||||
}: MessageActionProps) => {
|
||||
const button = (
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
interface MessageBranchContextType {
|
||||
currentBranch: number;
|
||||
totalBranches: number;
|
||||
goToPrevious: () => void;
|
||||
goToNext: () => void;
|
||||
branches: ReactElement[];
|
||||
setBranches: (branches: ReactElement[]) => void;
|
||||
}
|
||||
|
||||
const MessageBranchContext = createContext<MessageBranchContextType | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const useMessageBranch = () => {
|
||||
const context = useContext(MessageBranchContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"MessageBranch components must be used within MessageBranch"
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||
defaultBranch?: number;
|
||||
onBranchChange?: (branchIndex: number) => void;
|
||||
};
|
||||
|
||||
export const MessageBranch = ({
|
||||
defaultBranch = 0,
|
||||
onBranchChange,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchProps) => {
|
||||
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
|
||||
const [branches, setBranches] = useState<ReactElement[]>([]);
|
||||
|
||||
const handleBranchChange = useCallback(
|
||||
(newBranch: number) => {
|
||||
setCurrentBranch(newBranch);
|
||||
onBranchChange?.(newBranch);
|
||||
},
|
||||
[onBranchChange]
|
||||
);
|
||||
|
||||
const goToPrevious = useCallback(() => {
|
||||
const newBranch =
|
||||
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
||||
handleBranchChange(newBranch);
|
||||
}, [currentBranch, branches.length, handleBranchChange]);
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
const newBranch =
|
||||
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
||||
handleBranchChange(newBranch);
|
||||
}, [currentBranch, branches.length, handleBranchChange]);
|
||||
|
||||
const contextValue = useMemo<MessageBranchContextType>(
|
||||
() => ({
|
||||
branches,
|
||||
currentBranch,
|
||||
goToNext,
|
||||
goToPrevious,
|
||||
setBranches,
|
||||
totalBranches: branches.length,
|
||||
}),
|
||||
[branches, currentBranch, goToNext, goToPrevious]
|
||||
);
|
||||
|
||||
return (
|
||||
<MessageBranchContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
</MessageBranchContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageBranchContent = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchContentProps) => {
|
||||
const { currentBranch, setBranches, branches } = useMessageBranch();
|
||||
const childrenArray = useMemo(
|
||||
() => (Array.isArray(children) ? children : [children]),
|
||||
[children]
|
||||
);
|
||||
|
||||
// Use useEffect to update branches when they change
|
||||
useEffect(() => {
|
||||
if (branches.length !== childrenArray.length) {
|
||||
setBranches(childrenArray);
|
||||
}
|
||||
}, [childrenArray, branches, setBranches]);
|
||||
|
||||
return childrenArray.map((branch, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2 overflow-hidden [&>div]:pb-0",
|
||||
index === currentBranch ? "block" : "hidden"
|
||||
)}
|
||||
key={branch.key}
|
||||
{...props}
|
||||
>
|
||||
{branch}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>;
|
||||
|
||||
export const MessageBranchSelector = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchSelectorProps) => {
|
||||
const { totalBranches } = useMessageBranch();
|
||||
|
||||
// Don't render if there's only one branch
|
||||
if (totalBranches <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonGroup
|
||||
className={cn(
|
||||
"[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
|
||||
className
|
||||
)}
|
||||
orientation="horizontal"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchPrevious = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchPreviousProps) => {
|
||||
const { goToPrevious, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Previous branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToPrevious}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronLeftIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchNextProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchNext = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchNextProps) => {
|
||||
const { goToNext, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Next branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToNext}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const MessageBranchPage = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchPageProps) => {
|
||||
const { currentBranch, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<ButtonGroupText
|
||||
className={cn(
|
||||
"border-none bg-transparent text-muted-foreground shadow-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{currentBranch + 1} of {totalBranches}
|
||||
</ButtonGroupText>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
const streamdownPlugins = { cjk, code, math, mermaid };
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className
|
||||
)}
|
||||
plugins={streamdownPlugins}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.children === nextProps.children &&
|
||||
nextProps.isAnimating === prevProps.isAnimating
|
||||
);
|
||||
|
||||
MessageResponse.displayName = "MessageResponse";
|
||||
|
||||
export type MessageToolbarProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageToolbar = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageToolbarProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex w-full items-center justify-between gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,370 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronsUpDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
const deviceIdRegex = /\(([\da-fA-F]{4}:[\da-fA-F]{4})\)$/;
|
||||
|
||||
interface MicSelectorContextType {
|
||||
data: MediaDeviceInfo[];
|
||||
value: string | undefined;
|
||||
onValueChange?: (value: string) => void;
|
||||
open: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
width: number;
|
||||
setWidth?: (width: number) => void;
|
||||
}
|
||||
|
||||
const MicSelectorContext = createContext<MicSelectorContextType>({
|
||||
data: [],
|
||||
onOpenChange: undefined,
|
||||
onValueChange: undefined,
|
||||
open: false,
|
||||
setWidth: undefined,
|
||||
value: undefined,
|
||||
width: 200,
|
||||
});
|
||||
|
||||
export const useAudioDevices = () => {
|
||||
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasPermission, setHasPermission] = useState(false);
|
||||
|
||||
const loadDevicesWithoutPermission = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const deviceList = await navigator.mediaDevices.enumerateDevices();
|
||||
const audioInputs = deviceList.filter(
|
||||
(device) => device.kind === "audioinput"
|
||||
);
|
||||
|
||||
setDevices(audioInputs);
|
||||
} catch (caughtError) {
|
||||
const message =
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: "Failed to get audio devices";
|
||||
|
||||
setError(message);
|
||||
console.error("Error getting audio devices:", message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadDevicesWithPermission = useCallback(async () => {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const tempStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
});
|
||||
|
||||
for (const track of tempStream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
|
||||
const deviceList = await navigator.mediaDevices.enumerateDevices();
|
||||
const audioInputs = deviceList.filter(
|
||||
(device) => device.kind === "audioinput"
|
||||
);
|
||||
|
||||
setDevices(audioInputs);
|
||||
setHasPermission(true);
|
||||
} catch (caughtError) {
|
||||
const message =
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: "Failed to get audio devices";
|
||||
|
||||
setError(message);
|
||||
console.error("Error getting audio devices:", message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loading]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDevicesWithoutPermission();
|
||||
}, [loadDevicesWithoutPermission]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDeviceChange = () => {
|
||||
if (hasPermission) {
|
||||
loadDevicesWithPermission();
|
||||
} else {
|
||||
loadDevicesWithoutPermission();
|
||||
}
|
||||
};
|
||||
|
||||
navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange);
|
||||
|
||||
return () => {
|
||||
navigator.mediaDevices.removeEventListener(
|
||||
"devicechange",
|
||||
handleDeviceChange
|
||||
);
|
||||
};
|
||||
}, [hasPermission, loadDevicesWithPermission, loadDevicesWithoutPermission]);
|
||||
|
||||
return {
|
||||
devices,
|
||||
error,
|
||||
hasPermission,
|
||||
loadDevices: loadDevicesWithPermission,
|
||||
loading,
|
||||
};
|
||||
};
|
||||
|
||||
export type MicSelectorProps = ComponentProps<typeof Popover> & {
|
||||
defaultValue?: string;
|
||||
value?: string | undefined;
|
||||
onValueChange?: (value: string | undefined) => void;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const MicSelector = ({
|
||||
defaultValue,
|
||||
value: controlledValue,
|
||||
onValueChange: controlledOnValueChange,
|
||||
defaultOpen = false,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
...props
|
||||
}: MicSelectorProps) => {
|
||||
const [value, onValueChange] = useControllableState<string | undefined>({
|
||||
defaultProp: defaultValue,
|
||||
onChange: controlledOnValueChange,
|
||||
prop: controlledValue,
|
||||
});
|
||||
const [open, onOpenChange] = useControllableState({
|
||||
defaultProp: defaultOpen,
|
||||
onChange: controlledOnOpenChange,
|
||||
prop: controlledOpen,
|
||||
});
|
||||
const [width, setWidth] = useState(200);
|
||||
const { devices, loading, hasPermission, loadDevices } = useAudioDevices();
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !hasPermission && !loading) {
|
||||
loadDevices();
|
||||
}
|
||||
}, [open, hasPermission, loading, loadDevices]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
data: devices,
|
||||
onOpenChange,
|
||||
onValueChange,
|
||||
open,
|
||||
setWidth,
|
||||
value,
|
||||
width,
|
||||
}),
|
||||
[devices, onOpenChange, onValueChange, open, setWidth, value, width]
|
||||
);
|
||||
|
||||
return (
|
||||
<MicSelectorContext.Provider value={contextValue}>
|
||||
<Popover {...props} onOpenChange={onOpenChange} open={open} />
|
||||
</MicSelectorContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type MicSelectorTriggerProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MicSelectorTrigger = ({
|
||||
children,
|
||||
...props
|
||||
}: MicSelectorTriggerProps) => {
|
||||
const { setWidth } = useContext(MicSelectorContext);
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Create a ResizeObserver to detect width changes
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const newWidth = (entry.target as HTMLElement).offsetWidth;
|
||||
if (newWidth) {
|
||||
setWidth?.(newWidth);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (ref.current) {
|
||||
resizeObserver.observe(ref.current);
|
||||
}
|
||||
|
||||
// Clean up the observer when component unmounts
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [setWidth]);
|
||||
|
||||
return (
|
||||
<PopoverTrigger render={<Button variant="outline" {...props} ref={ref} />}>{children}<ChevronsUpDownIcon
|
||||
className="shrink-0 text-muted-foreground"
|
||||
size={16}
|
||||
/></PopoverTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export type MicSelectorContentProps = ComponentProps<typeof Command> & {
|
||||
popoverOptions?: ComponentProps<typeof PopoverContent>;
|
||||
};
|
||||
|
||||
export const MicSelectorContent = ({
|
||||
className,
|
||||
popoverOptions,
|
||||
...props
|
||||
}: MicSelectorContentProps) => {
|
||||
const { width, onValueChange, value } = useContext(MicSelectorContext);
|
||||
|
||||
return (
|
||||
<PopoverContent
|
||||
className={cn("p-0", className)}
|
||||
style={{ width }}
|
||||
{...popoverOptions}
|
||||
>
|
||||
<Command onValueChange={onValueChange} value={value} {...props} />
|
||||
</PopoverContent>
|
||||
);
|
||||
};
|
||||
|
||||
export type MicSelectorInputProps = ComponentProps<typeof CommandInput> & {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
export const MicSelectorInput = ({ ...props }: MicSelectorInputProps) => (
|
||||
<CommandInput placeholder="Search microphones..." {...props} />
|
||||
);
|
||||
|
||||
export type MicSelectorListProps = Omit<
|
||||
ComponentProps<typeof CommandList>,
|
||||
"children"
|
||||
> & {
|
||||
children: (devices: MediaDeviceInfo[]) => ReactNode;
|
||||
};
|
||||
|
||||
export const MicSelectorList = ({
|
||||
children,
|
||||
...props
|
||||
}: MicSelectorListProps) => {
|
||||
const { data } = useContext(MicSelectorContext);
|
||||
|
||||
return <CommandList {...props}>{children(data)}</CommandList>;
|
||||
};
|
||||
|
||||
export type MicSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
|
||||
|
||||
export const MicSelectorEmpty = ({
|
||||
children = "No microphone found.",
|
||||
...props
|
||||
}: MicSelectorEmptyProps) => <CommandEmpty {...props}>{children}</CommandEmpty>;
|
||||
|
||||
export type MicSelectorItemProps = ComponentProps<typeof CommandItem>;
|
||||
|
||||
export const MicSelectorItem = (props: MicSelectorItemProps) => {
|
||||
const { onValueChange, onOpenChange } = useContext(MicSelectorContext);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(currentValue: string) => {
|
||||
onValueChange?.(currentValue);
|
||||
onOpenChange?.(false);
|
||||
},
|
||||
[onValueChange, onOpenChange]
|
||||
);
|
||||
|
||||
return <CommandItem onSelect={handleSelect} {...props} />;
|
||||
};
|
||||
|
||||
export type MicSelectorLabelProps = ComponentProps<"span"> & {
|
||||
device: MediaDeviceInfo;
|
||||
};
|
||||
|
||||
export const MicSelectorLabel = ({
|
||||
device,
|
||||
className,
|
||||
...props
|
||||
}: MicSelectorLabelProps) => {
|
||||
const matches = device.label.match(deviceIdRegex);
|
||||
|
||||
if (!matches) {
|
||||
return (
|
||||
<span className={className} {...props}>
|
||||
{device.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const [, deviceId] = matches;
|
||||
const name = device.label.replace(deviceIdRegex, "");
|
||||
|
||||
return (
|
||||
<span className={className} {...props}>
|
||||
<span>{name}</span>
|
||||
<span className="text-muted-foreground"> ({deviceId})</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type MicSelectorValueProps = ComponentProps<"span">;
|
||||
|
||||
export const MicSelectorValue = ({
|
||||
className,
|
||||
...props
|
||||
}: MicSelectorValueProps) => {
|
||||
const { data, value } = useContext(MicSelectorContext);
|
||||
const currentDevice = data.find((d) => d.deviceId === value);
|
||||
|
||||
if (!currentDevice) {
|
||||
return (
|
||||
<span className={cn("flex-1 text-left", className)} {...props}>
|
||||
Select microphone...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MicSelectorLabel
|
||||
className={cn("flex-1 text-left", className)}
|
||||
device={currentDevice}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
|
||||
export type ModelSelectorProps = ComponentProps<typeof Dialog>;
|
||||
|
||||
export const ModelSelector = (props: ModelSelectorProps) => (
|
||||
<Dialog {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
|
||||
|
||||
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
|
||||
<DialogTrigger {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
||||
title?: ReactNode;
|
||||
};
|
||||
|
||||
export const ModelSelectorContent = ({
|
||||
className,
|
||||
children,
|
||||
title = "Model Selector",
|
||||
...props
|
||||
}: ModelSelectorContentProps) => (
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
className={cn(
|
||||
"outline! border-none! p-0 outline-border! outline-solid!",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
);
|
||||
|
||||
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>;
|
||||
|
||||
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
|
||||
<CommandDialog {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>;
|
||||
|
||||
export const ModelSelectorInput = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorInputProps) => (
|
||||
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorListProps = ComponentProps<typeof CommandList>;
|
||||
|
||||
export const ModelSelectorList = (props: ModelSelectorListProps) => (
|
||||
<CommandList {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
|
||||
|
||||
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
|
||||
<CommandEmpty {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>;
|
||||
|
||||
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
|
||||
<CommandGroup {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>;
|
||||
|
||||
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
|
||||
<CommandItem {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
|
||||
|
||||
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
|
||||
<CommandShortcut {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorSeparatorProps = ComponentProps<
|
||||
typeof CommandSeparator
|
||||
>;
|
||||
|
||||
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
|
||||
<CommandSeparator {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorLogoProps = Omit<
|
||||
ComponentProps<"img">,
|
||||
"src" | "alt"
|
||||
> & {
|
||||
provider:
|
||||
| "moonshotai-cn"
|
||||
| "lucidquery"
|
||||
| "moonshotai"
|
||||
| "zai-coding-plan"
|
||||
| "alibaba"
|
||||
| "xai"
|
||||
| "vultr"
|
||||
| "nvidia"
|
||||
| "upstage"
|
||||
| "groq"
|
||||
| "github-copilot"
|
||||
| "mistral"
|
||||
| "vercel"
|
||||
| "nebius"
|
||||
| "deepseek"
|
||||
| "alibaba-cn"
|
||||
| "google-vertex-anthropic"
|
||||
| "venice"
|
||||
| "chutes"
|
||||
| "cortecs"
|
||||
| "github-models"
|
||||
| "togetherai"
|
||||
| "azure"
|
||||
| "baseten"
|
||||
| "huggingface"
|
||||
| "opencode"
|
||||
| "fastrouter"
|
||||
| "google"
|
||||
| "google-vertex"
|
||||
| "cloudflare-workers-ai"
|
||||
| "inception"
|
||||
| "wandb"
|
||||
| "openai"
|
||||
| "zhipuai-coding-plan"
|
||||
| "perplexity"
|
||||
| "openrouter"
|
||||
| "zenmux"
|
||||
| "v0"
|
||||
| "iflowcn"
|
||||
| "synthetic"
|
||||
| "deepinfra"
|
||||
| "zhipuai"
|
||||
| "submodel"
|
||||
| "zai"
|
||||
| "inference"
|
||||
| "requesty"
|
||||
| "morph"
|
||||
| "lmstudio"
|
||||
| "anthropic"
|
||||
| "aihubmix"
|
||||
| "fireworks-ai"
|
||||
| "modelscope"
|
||||
| "llama"
|
||||
| "scaleway"
|
||||
| "amazon-bedrock"
|
||||
| "cerebras"
|
||||
// oxlint-disable-next-line typescript-eslint(ban-types) -- intentional pattern for autocomplete-friendly string union
|
||||
| (string & {});
|
||||
};
|
||||
|
||||
export const ModelSelectorLogo = ({
|
||||
provider,
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoProps) => (
|
||||
<img
|
||||
{...props}
|
||||
alt={`${provider} logo`}
|
||||
className={cn("size-3 dark:invert", className)}
|
||||
height={12}
|
||||
src={`https://models.dev/logos/${provider}.svg`}
|
||||
width={12}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ModelSelectorLogoGroupProps = ComponentProps<"div">;
|
||||
|
||||
export const ModelSelectorLogoGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoGroupProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center -space-x-1 [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ModelSelectorNameProps = ComponentProps<"span">;
|
||||
|
||||
export const ModelSelectorName = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorNameProps) => (
|
||||
<span className={cn("flex-1 truncate text-left", className)} {...props} />
|
||||
);
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type NodeProps = ComponentProps<typeof Card> & {
|
||||
handles: {
|
||||
target: boolean;
|
||||
source: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const Node = ({ handles, className, ...props }: NodeProps) => (
|
||||
<Card
|
||||
className={cn(
|
||||
"node-container relative size-full h-auto w-sm gap-0 rounded-md p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{handles.target && <Handle position={Position.Left} type="target" />}
|
||||
{handles.source && <Handle position={Position.Right} type="source" />}
|
||||
{props.children}
|
||||
</Card>
|
||||
);
|
||||
|
||||
export type NodeHeaderProps = ComponentProps<typeof CardHeader>;
|
||||
|
||||
export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => (
|
||||
<CardHeader
|
||||
className={cn("gap-0.5 rounded-t-md border-b bg-secondary p-3!", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type NodeTitleProps = ComponentProps<typeof CardTitle>;
|
||||
|
||||
export const NodeTitle = (props: NodeTitleProps) => <CardTitle {...props} />;
|
||||
|
||||
export type NodeDescriptionProps = ComponentProps<typeof CardDescription>;
|
||||
|
||||
export const NodeDescription = (props: NodeDescriptionProps) => (
|
||||
<CardDescription {...props} />
|
||||
);
|
||||
|
||||
export type NodeActionProps = ComponentProps<typeof CardAction>;
|
||||
|
||||
export const NodeAction = (props: NodeActionProps) => <CardAction {...props} />;
|
||||
|
||||
export type NodeContentProps = ComponentProps<typeof CardContent>;
|
||||
|
||||
export const NodeContent = ({ className, ...props }: NodeContentProps) => (
|
||||
<CardContent className={cn("p-3", className)} {...props} />
|
||||
);
|
||||
|
||||
export type NodeFooterProps = ComponentProps<typeof CardFooter>;
|
||||
|
||||
export const NodeFooter = ({ className, ...props }: NodeFooterProps) => (
|
||||
<CardFooter
|
||||
className={cn("rounded-b-md border-t bg-secondary p-3!", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ExternalLinkIcon,
|
||||
MessageCircleIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
const providers = {
|
||||
chatgpt: {
|
||||
createUrl: (prompt: string) =>
|
||||
`https://chatgpt.com/?${new URLSearchParams({
|
||||
hints: "search",
|
||||
prompt,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>OpenAI</title>
|
||||
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
|
||||
</svg>
|
||||
),
|
||||
title: "Open in ChatGPT",
|
||||
},
|
||||
claude: {
|
||||
createUrl: (q: string) =>
|
||||
`https://claude.ai/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
viewBox="0 0 12 12"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Claude</title>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M2.3545 7.9775L4.7145 6.654L4.7545 6.539L4.7145 6.475H4.6L4.205 6.451L2.856 6.4145L1.6865 6.366L0.5535 6.305L0.268 6.2445L0 5.892L0.0275 5.716L0.2675 5.5555L0.6105 5.5855L1.3705 5.637L2.5095 5.716L3.3355 5.7645L4.56 5.892H4.7545L4.782 5.8135L4.715 5.7645L4.6635 5.716L3.4845 4.918L2.2085 4.074L1.5405 3.588L1.1785 3.3425L0.9965 3.1115L0.9175 2.6075L1.2455 2.2465L1.686 2.2765L1.7985 2.307L2.245 2.65L3.199 3.388L4.4445 4.3045L4.627 4.4565L4.6995 4.405L4.709 4.3685L4.627 4.2315L3.9495 3.0085L3.2265 1.7635L2.9045 1.2475L2.8195 0.938C2.78711 0.819128 2.76965 0.696687 2.7675 0.5735L3.1415 0.067L3.348 0L3.846 0.067L4.056 0.249L4.366 0.956L4.867 2.0705L5.6445 3.5855L5.8725 4.0345L5.994 4.4505L6.0395 4.578H6.1185V4.505L6.1825 3.652L6.301 2.6045L6.416 1.257L6.456 0.877L6.644 0.422L7.0175 0.176L7.3095 0.316L7.5495 0.6585L7.516 0.8805L7.373 1.806L7.0935 3.2575L6.9115 4.2285H7.0175L7.139 4.1075L7.6315 3.4545L8.4575 2.4225L8.8225 2.0125L9.2475 1.5605L9.521 1.345H10.0375L10.4175 1.9095L10.2475 2.4925L9.7155 3.166L9.275 3.737L8.643 4.587L8.248 5.267L8.2845 5.322L8.3785 5.312L9.8065 5.009L10.578 4.869L11.4985 4.7115L11.915 4.9055L11.9605 5.103L11.7965 5.5065L10.812 5.7495L9.6575 5.9805L7.938 6.387L7.917 6.402L7.9415 6.4325L8.716 6.5055L9.047 6.5235H9.858L11.368 6.636L11.763 6.897L12 7.216L11.9605 7.4585L11.353 7.7685L10.533 7.574L8.6185 7.119L7.9625 6.9545H7.8715V7.0095L8.418 7.5435L9.421 8.4485L10.6755 9.6135L10.739 9.9025L10.578 10.13L10.408 10.1055L9.3055 9.277L8.88 8.9035L7.917 8.0935H7.853V8.1785L8.075 8.503L9.2475 10.2635L9.3085 10.8035L9.2235 10.98L8.9195 11.0865L8.5855 11.0255L7.8985 10.063L7.191 8.9795L6.6195 8.008L6.5495 8.048L6.2125 11.675L6.0545 11.86L5.69 12L5.3865 11.7695L5.2255 11.396L5.3865 10.658L5.581 9.696L5.7385 8.931L5.8815 7.981L5.9665 7.665L5.9605 7.644L5.8905 7.653L5.1735 8.6365L4.0835 10.109L3.2205 11.0315L3.0135 11.1135L2.655 10.9285L2.6885 10.5975L2.889 10.303L4.083 8.785L4.803 7.844L5.268 7.301L5.265 7.222H5.2375L2.066 9.28L1.501 9.353L1.2575 9.125L1.288 8.752L1.4035 8.6305L2.3575 7.9745L2.3545 7.9775Z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
title: "Open in Claude",
|
||||
},
|
||||
cursor: {
|
||||
createUrl: (text: string) => {
|
||||
const url = new URL("https://cursor.com/link/prompt");
|
||||
url.searchParams.set("text", text);
|
||||
return url.toString();
|
||||
},
|
||||
icon: (
|
||||
<svg
|
||||
version="1.1"
|
||||
viewBox="0 0 466.73 532.09"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Cursor</title>
|
||||
<path
|
||||
d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
title: "Open in Cursor",
|
||||
},
|
||||
github: {
|
||||
createUrl: (url: string) => url,
|
||||
icon: (
|
||||
<svg fill="currentColor" role="img" viewBox="0 0 24 24">
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
),
|
||||
title: "Open in GitHub",
|
||||
},
|
||||
scira: {
|
||||
createUrl: (q: string) =>
|
||||
`https://scira.ai/?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="none"
|
||||
height="934"
|
||||
viewBox="0 0 910 934"
|
||||
width="910"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Scira AI</title>
|
||||
<path
|
||||
d="M647.664 197.775C569.13 189.049 525.5 145.419 516.774 66.8849C508.048 145.419 464.418 189.049 385.884 197.775C464.418 206.501 508.048 250.131 516.774 328.665C525.5 250.131 569.13 206.501 647.664 197.775Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<path
|
||||
d="M516.774 304.217C510.299 275.491 498.208 252.087 480.335 234.214C462.462 216.341 439.058 204.251 410.333 197.775C439.059 191.3 462.462 179.209 480.335 161.336C498.208 143.463 510.299 120.06 516.774 91.334C523.25 120.059 535.34 143.463 553.213 161.336C571.086 179.209 594.49 191.3 623.216 197.775C594.49 204.251 571.086 216.341 553.213 234.214C535.34 252.087 523.25 275.491 516.774 304.217Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<path
|
||||
d="M857.5 508.116C763.259 497.644 710.903 445.288 700.432 351.047C689.961 445.288 637.605 497.644 543.364 508.116C637.605 518.587 689.961 570.943 700.432 665.184C710.903 570.943 763.259 518.587 857.5 508.116Z"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="20"
|
||||
/>
|
||||
<path
|
||||
d="M700.432 615.957C691.848 589.05 678.575 566.357 660.383 548.165C642.191 529.973 619.499 516.7 592.593 508.116C619.499 499.533 642.191 486.258 660.383 468.066C678.575 449.874 691.848 427.181 700.432 400.274C709.015 427.181 722.289 449.874 740.481 468.066C758.673 486.258 781.365 499.533 808.271 508.116C781.365 516.7 758.673 529.973 740.481 548.165C722.289 566.357 709.015 589.05 700.432 615.957Z"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="20"
|
||||
/>
|
||||
<path
|
||||
d="M889.949 121.237C831.049 114.692 798.326 81.9698 791.782 23.0692C785.237 81.9698 752.515 114.692 693.614 121.237C752.515 127.781 785.237 160.504 791.782 219.404C798.326 160.504 831.049 127.781 889.949 121.237Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<path
|
||||
d="M791.782 196.795C786.697 176.937 777.869 160.567 765.16 147.858C752.452 135.15 736.082 126.322 716.226 121.237C736.082 116.152 752.452 107.324 765.16 94.6152C777.869 81.9065 786.697 65.5368 791.782 45.6797C796.867 65.5367 805.695 81.9066 818.403 94.6152C831.112 107.324 847.481 116.152 867.338 121.237C847.481 126.322 831.112 135.15 818.403 147.858C805.694 160.567 796.867 176.937 791.782 196.795Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<path
|
||||
d="M760.632 764.337C720.719 814.616 669.835 855.1 611.872 882.692C553.91 910.285 490.404 924.255 426.213 923.533C362.022 922.812 298.846 907.419 241.518 878.531C184.19 849.643 134.228 808.026 95.4548 756.863C56.6815 705.7 30.1238 646.346 17.8129 583.343C5.50207 520.339 7.76433 455.354 24.4266 393.359C41.089 331.364 71.7099 274.001 113.947 225.658C156.184 177.315 208.919 139.273 268.117 114.442"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="30"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
title: "Open in Scira",
|
||||
},
|
||||
t3: {
|
||||
createUrl: (q: string) =>
|
||||
`https://t3.chat/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: <MessageCircleIcon />,
|
||||
title: "Open in T3 Chat",
|
||||
},
|
||||
v0: {
|
||||
createUrl: (q: string) =>
|
||||
`https://v0.app?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 147 70"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>v0</title>
|
||||
<path d="M56 50.2031V14H70V60.1562C70 65.5928 65.5928 70 60.1562 70C57.5605 70 54.9982 68.9992 53.1562 67.1573L0 14H19.7969L56 50.2031Z" />
|
||||
<path d="M147 56H133V23.9531L100.953 56H133V70H96.6875C85.8144 70 77 61.1856 77 50.3125V14H91V46.1562L123.156 14H91V0H127.312C138.186 0 147 8.81439 147 19.6875V56Z" />
|
||||
</svg>
|
||||
),
|
||||
title: "Open in v0",
|
||||
},
|
||||
};
|
||||
|
||||
const OpenInContext = createContext<{ query: string } | undefined>(undefined);
|
||||
|
||||
const useOpenInContext = () => {
|
||||
const context = useContext(OpenInContext);
|
||||
if (!context) {
|
||||
throw new Error("OpenIn components must be used within an OpenIn provider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type OpenInProps = ComponentProps<typeof DropdownMenu> & {
|
||||
query: string;
|
||||
};
|
||||
|
||||
export const OpenIn = ({ query, ...props }: OpenInProps) => {
|
||||
const contextValue = useMemo(() => ({ query }), [query]);
|
||||
|
||||
return (
|
||||
<OpenInContext.Provider value={contextValue}>
|
||||
<DropdownMenu {...props} />
|
||||
</OpenInContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInContentProps = ComponentProps<typeof DropdownMenuContent>;
|
||||
|
||||
export const OpenInContent = ({ className, ...props }: OpenInContentProps) => (
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={cn("w-[240px]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type OpenInItemProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInItem = (props: OpenInItemProps) => (
|
||||
<DropdownMenuItem {...props} />
|
||||
);
|
||||
|
||||
export type OpenInLabelProps = ComponentProps<typeof DropdownMenuLabel>;
|
||||
|
||||
export const OpenInLabel = (props: OpenInLabelProps) => (
|
||||
<DropdownMenuLabel {...props} />
|
||||
);
|
||||
|
||||
export type OpenInSeparatorProps = ComponentProps<typeof DropdownMenuSeparator>;
|
||||
|
||||
export const OpenInSeparator = (props: OpenInSeparatorProps) => (
|
||||
<DropdownMenuSeparator {...props} />
|
||||
);
|
||||
|
||||
export type OpenInTriggerProps = ComponentProps<typeof DropdownMenuTrigger>;
|
||||
|
||||
export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => (
|
||||
<DropdownMenuTrigger {...props}>
|
||||
{children ?? (
|
||||
<Button type="button" variant="outline">
|
||||
Open in chat
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
);
|
||||
|
||||
export type OpenInChatGPTProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem {...props} render={<a className="flex items-center gap-2" href={providers.chatgpt.createUrl(query)} rel="noopener" target="_blank" />}><span className="shrink-0">{providers.chatgpt.icon}</span><span className="flex-1">{providers.chatgpt.title}</span><ExternalLinkIcon className="size-4 shrink-0" /></DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInClaudeProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInClaude = (props: OpenInClaudeProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem {...props} render={<a className="flex items-center gap-2" href={providers.claude.createUrl(query)} rel="noopener" target="_blank" />}><span className="shrink-0">{providers.claude.icon}</span><span className="flex-1">{providers.claude.title}</span><ExternalLinkIcon className="size-4 shrink-0" /></DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInT3Props = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInT3 = (props: OpenInT3Props) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem {...props} render={<a className="flex items-center gap-2" href={providers.t3.createUrl(query)} rel="noopener" target="_blank" />}><span className="shrink-0">{providers.t3.icon}</span><span className="flex-1">{providers.t3.title}</span><ExternalLinkIcon className="size-4 shrink-0" /></DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInSciraProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInScira = (props: OpenInSciraProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem {...props} render={<a className="flex items-center gap-2" href={providers.scira.createUrl(query)} rel="noopener" target="_blank" />}><span className="shrink-0">{providers.scira.icon}</span><span className="flex-1">{providers.scira.title}</span><ExternalLinkIcon className="size-4 shrink-0" /></DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInv0Props = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInv0 = (props: OpenInv0Props) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem {...props} render={<a className="flex items-center gap-2" href={providers.v0.createUrl(query)} rel="noopener" target="_blank" />}><span className="shrink-0">{providers.v0.icon}</span><span className="flex-1">{providers.v0.title}</span><ExternalLinkIcon className="size-4 shrink-0" /></DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInCursorProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInCursor = (props: OpenInCursorProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem {...props} render={<a className="flex items-center gap-2" href={providers.cursor.createUrl(query)} rel="noopener" target="_blank" />}><span className="shrink-0">{providers.cursor.icon}</span><span className="flex-1">{providers.cursor.title}</span><ExternalLinkIcon className="size-4 shrink-0" /></DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowRightIcon, MinusIcon, PackageIcon, PlusIcon } from "lucide-react";
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
type ChangeType = "major" | "minor" | "patch" | "added" | "removed";
|
||||
|
||||
interface PackageInfoContextType {
|
||||
name: string;
|
||||
currentVersion?: string;
|
||||
newVersion?: string;
|
||||
changeType?: ChangeType;
|
||||
}
|
||||
|
||||
const PackageInfoContext = createContext<PackageInfoContextType>({
|
||||
name: "",
|
||||
});
|
||||
|
||||
export type PackageInfoHeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PackageInfoHeader = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoHeaderProps) => (
|
||||
<div
|
||||
className={cn("flex items-center justify-between gap-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type PackageInfoNameProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PackageInfoName = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoNameProps) => {
|
||||
const { name } = useContext(PackageInfoContext);
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)} {...props}>
|
||||
<PackageIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium font-mono text-sm">{children ?? name}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const changeTypeStyles: Record<ChangeType, string> = {
|
||||
added: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
||||
major: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
|
||||
minor:
|
||||
"bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
|
||||
patch: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||
removed: "bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400",
|
||||
};
|
||||
|
||||
const changeTypeIcons: Record<ChangeType, React.ReactNode> = {
|
||||
added: <PlusIcon className="size-3" />,
|
||||
major: <ArrowRightIcon className="size-3" />,
|
||||
minor: <ArrowRightIcon className="size-3" />,
|
||||
patch: <ArrowRightIcon className="size-3" />,
|
||||
removed: <MinusIcon className="size-3" />,
|
||||
};
|
||||
|
||||
export type PackageInfoChangeTypeProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PackageInfoChangeType = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoChangeTypeProps) => {
|
||||
const { changeType } = useContext(PackageInfoContext);
|
||||
|
||||
if (!changeType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className={cn(
|
||||
"gap-1 text-xs capitalize",
|
||||
changeTypeStyles[changeType],
|
||||
className
|
||||
)}
|
||||
variant="secondary"
|
||||
{...props}
|
||||
>
|
||||
{changeTypeIcons[changeType]}
|
||||
{children ?? changeType}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export type PackageInfoVersionProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PackageInfoVersion = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoVersionProps) => {
|
||||
const { currentVersion, newVersion } = useContext(PackageInfoContext);
|
||||
|
||||
if (!(currentVersion || newVersion)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-2 flex items-center gap-2 font-mono text-muted-foreground text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{currentVersion && <span>{currentVersion}</span>}
|
||||
{currentVersion && newVersion && (
|
||||
<ArrowRightIcon className="size-3" />
|
||||
)}
|
||||
{newVersion && (
|
||||
<span className="font-medium text-foreground">{newVersion}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type PackageInfoProps = HTMLAttributes<HTMLDivElement> & {
|
||||
name: string;
|
||||
currentVersion?: string;
|
||||
newVersion?: string;
|
||||
changeType?: ChangeType;
|
||||
};
|
||||
|
||||
export const PackageInfo = ({
|
||||
name,
|
||||
currentVersion,
|
||||
newVersion,
|
||||
changeType,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoProps) => {
|
||||
const contextValue = useMemo(
|
||||
() => ({ changeType, currentVersion, name, newVersion }),
|
||||
[changeType, currentVersion, name, newVersion]
|
||||
);
|
||||
|
||||
return (
|
||||
<PackageInfoContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("rounded-lg border bg-background p-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<PackageInfoHeader>
|
||||
<PackageInfoName />
|
||||
{changeType && <PackageInfoChangeType />}
|
||||
</PackageInfoHeader>
|
||||
{(currentVersion || newVersion) && <PackageInfoVersion />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PackageInfoContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type PackageInfoDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const PackageInfoDescription = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoDescriptionProps) => (
|
||||
<p className={cn("mt-2 text-muted-foreground text-sm", className)} {...props}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
export type PackageInfoContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PackageInfoContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoContentProps) => (
|
||||
<div className={cn("mt-3 border-t pt-3", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type PackageInfoDependenciesProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PackageInfoDependencies = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoDependenciesProps) => (
|
||||
<div className={cn("space-y-2", className)} {...props}>
|
||||
<span className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Dependencies
|
||||
</span>
|
||||
<div className="space-y-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export type PackageInfoDependencyProps = HTMLAttributes<HTMLDivElement> & {
|
||||
name: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
export const PackageInfoDependency = ({
|
||||
name,
|
||||
version,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoDependencyProps) => (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<span className="font-mono text-muted-foreground">{name}</span>
|
||||
{version && <span className="font-mono text-xs">{version}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Panel as PanelPrimitive } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
type PanelProps = ComponentProps<typeof PanelPrimitive>;
|
||||
|
||||
export const Panel = ({ className, ...props }: PanelProps) => (
|
||||
<PanelPrimitive
|
||||
className={cn(
|
||||
"m-4 overflow-hidden rounded-md border bg-card p-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { RiveParameters } from "@rive-app/react-webgl2";
|
||||
import {
|
||||
useRive,
|
||||
useStateMachineInput,
|
||||
useViewModel,
|
||||
useViewModelInstance,
|
||||
useViewModelInstanceColor,
|
||||
} from "@rive-app/react-webgl2";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
// Delays Rive initialization by one frame so that React Strict Mode's
|
||||
// immediate unmount cycle never creates a WebGL2 context. Only the
|
||||
// second (real) mount will initialise, avoiding context exhaustion.
|
||||
const useStrictModeSafeInit = () => {
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => setReady(true));
|
||||
return () => {
|
||||
cancelAnimationFrame(id);
|
||||
setReady(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return ready;
|
||||
};
|
||||
|
||||
export type PersonaState =
|
||||
| "idle"
|
||||
| "listening"
|
||||
| "thinking"
|
||||
| "speaking"
|
||||
| "asleep";
|
||||
|
||||
interface PersonaProps {
|
||||
state: PersonaState;
|
||||
onLoad?: RiveParameters["onLoad"];
|
||||
onLoadError?: RiveParameters["onLoadError"];
|
||||
onReady?: () => void;
|
||||
onPause?: RiveParameters["onPause"];
|
||||
onPlay?: RiveParameters["onPlay"];
|
||||
onStop?: RiveParameters["onStop"];
|
||||
className?: string;
|
||||
variant?: keyof typeof sources;
|
||||
}
|
||||
|
||||
// The state machine name is always 'default' for Elements AI visuals
|
||||
const stateMachine = "default";
|
||||
|
||||
const sources = {
|
||||
command: {
|
||||
dynamicColor: true,
|
||||
hasModel: true,
|
||||
source:
|
||||
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/command-2.0.riv",
|
||||
},
|
||||
glint: {
|
||||
dynamicColor: true,
|
||||
hasModel: true,
|
||||
source:
|
||||
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/glint-2.0.riv",
|
||||
},
|
||||
halo: {
|
||||
dynamicColor: true,
|
||||
hasModel: true,
|
||||
source:
|
||||
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/halo-2.0.riv",
|
||||
},
|
||||
mana: {
|
||||
dynamicColor: false,
|
||||
hasModel: true,
|
||||
source:
|
||||
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/mana-2.0.riv",
|
||||
},
|
||||
obsidian: {
|
||||
dynamicColor: true,
|
||||
hasModel: true,
|
||||
source:
|
||||
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv",
|
||||
},
|
||||
opal: {
|
||||
dynamicColor: false,
|
||||
hasModel: false,
|
||||
source:
|
||||
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/orb-1.2.riv",
|
||||
},
|
||||
};
|
||||
|
||||
const getCurrentTheme = (): "light" | "dark" => {
|
||||
if (typeof window !== "undefined") {
|
||||
if (document.documentElement.classList.contains("dark")) {
|
||||
return "dark";
|
||||
}
|
||||
if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
|
||||
return "dark";
|
||||
}
|
||||
}
|
||||
return "light";
|
||||
};
|
||||
|
||||
const useTheme = (enabled: boolean) => {
|
||||
const [theme, setTheme] = useState<"light" | "dark">(getCurrentTheme);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip if not enabled (avoids unnecessary observers for non-dynamic-color variants)
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Watch for classList changes
|
||||
const observer = new MutationObserver(() => {
|
||||
setTheme(getCurrentTheme());
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributeFilter: ["class"],
|
||||
attributes: true,
|
||||
});
|
||||
|
||||
// Watch for OS-level theme changes
|
||||
let mql: MediaQueryList | null = null;
|
||||
const handleMediaChange = () => {
|
||||
setTheme(getCurrentTheme());
|
||||
};
|
||||
|
||||
if (window.matchMedia) {
|
||||
mql = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mql.addEventListener("change", handleMediaChange);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (mql) {
|
||||
mql.removeEventListener("change", handleMediaChange);
|
||||
}
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return theme;
|
||||
};
|
||||
|
||||
interface PersonaWithModelProps {
|
||||
rive: ReturnType<typeof useRive>["rive"];
|
||||
source: (typeof sources)[keyof typeof sources];
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const PersonaWithModel = memo(
|
||||
({ rive, source, children }: PersonaWithModelProps) => {
|
||||
const theme = useTheme(source.dynamicColor);
|
||||
const viewModel = useViewModel(rive, { useDefault: true });
|
||||
const viewModelInstance = useViewModelInstance(viewModel, {
|
||||
rive,
|
||||
useDefault: true,
|
||||
});
|
||||
const viewModelInstanceColor = useViewModelInstanceColor(
|
||||
"color",
|
||||
viewModelInstance
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(viewModelInstanceColor && source.dynamicColor)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [r, g, b] = theme === "dark" ? [255, 255, 255] : [0, 0, 0];
|
||||
viewModelInstanceColor.setRgb(r, g, b);
|
||||
}, [viewModelInstanceColor, theme, source.dynamicColor]);
|
||||
|
||||
return children;
|
||||
}
|
||||
);
|
||||
|
||||
PersonaWithModel.displayName = "PersonaWithModel";
|
||||
|
||||
interface PersonaWithoutModelProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const PersonaWithoutModel = memo(
|
||||
({ children }: PersonaWithoutModelProps) => children
|
||||
);
|
||||
|
||||
PersonaWithoutModel.displayName = "PersonaWithoutModel";
|
||||
|
||||
export const Persona: FC<PersonaProps> = memo(
|
||||
({
|
||||
variant = "obsidian",
|
||||
state = "idle",
|
||||
onLoad,
|
||||
onLoadError,
|
||||
onReady,
|
||||
onPause,
|
||||
onPlay,
|
||||
onStop,
|
||||
className,
|
||||
}) => {
|
||||
const source = sources[variant];
|
||||
|
||||
if (!source) {
|
||||
throw new Error(`Invalid variant: ${variant}`);
|
||||
}
|
||||
|
||||
// Stabilize callbacks to prevent useRive from reinitializing
|
||||
const callbacksRef = useRef({
|
||||
onLoad,
|
||||
onLoadError,
|
||||
onPause,
|
||||
onPlay,
|
||||
onReady,
|
||||
onStop,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
callbacksRef.current = {
|
||||
onLoad,
|
||||
onLoadError,
|
||||
onPause,
|
||||
onPlay,
|
||||
onReady,
|
||||
onStop,
|
||||
};
|
||||
}, [onLoad, onLoadError, onPause, onPlay, onReady, onStop]);
|
||||
|
||||
const stableCallbacks = useMemo(
|
||||
() => ({
|
||||
onLoad: ((loadedRive) =>
|
||||
callbacksRef.current.onLoad?.(
|
||||
loadedRive
|
||||
)) as RiveParameters["onLoad"],
|
||||
onLoadError: ((err) =>
|
||||
callbacksRef.current.onLoadError?.(
|
||||
err
|
||||
)) as RiveParameters["onLoadError"],
|
||||
onPause: ((event) =>
|
||||
callbacksRef.current.onPause?.(event)) as RiveParameters["onPause"],
|
||||
onPlay: ((event) =>
|
||||
callbacksRef.current.onPlay?.(event)) as RiveParameters["onPlay"],
|
||||
onReady: () => callbacksRef.current.onReady?.(),
|
||||
onStop: ((event) =>
|
||||
callbacksRef.current.onStop?.(event)) as RiveParameters["onStop"],
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// Delay initialisation by one frame to avoid creating (and leaking)
|
||||
// a WebGL2 context during React Strict Mode's first throw-away mount.
|
||||
const ready = useStrictModeSafeInit();
|
||||
|
||||
const { rive, RiveComponent } = useRive(
|
||||
ready
|
||||
? {
|
||||
autoplay: true,
|
||||
onLoad: stableCallbacks.onLoad,
|
||||
onLoadError: stableCallbacks.onLoadError,
|
||||
onPause: stableCallbacks.onPause,
|
||||
onPlay: stableCallbacks.onPlay,
|
||||
onRiveReady: stableCallbacks.onReady,
|
||||
onStop: stableCallbacks.onStop,
|
||||
src: source.source,
|
||||
stateMachines: stateMachine,
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
const listeningInput = useStateMachineInput(
|
||||
rive,
|
||||
stateMachine,
|
||||
"listening"
|
||||
);
|
||||
const thinkingInput = useStateMachineInput(rive, stateMachine, "thinking");
|
||||
const speakingInput = useStateMachineInput(rive, stateMachine, "speaking");
|
||||
const asleepInput = useStateMachineInput(rive, stateMachine, "asleep");
|
||||
|
||||
// Rive state machine inputs are mutable objects that must be set via direct
|
||||
// property assignment — this is the intended Rive API, not a React anti-pattern.
|
||||
useEffect(() => {
|
||||
if (listeningInput) {
|
||||
listeningInput.value = state === "listening";
|
||||
}
|
||||
if (thinkingInput) {
|
||||
thinkingInput.value = state === "thinking";
|
||||
}
|
||||
if (speakingInput) {
|
||||
speakingInput.value = state === "speaking";
|
||||
}
|
||||
if (asleepInput) {
|
||||
asleepInput.value = state === "asleep";
|
||||
}
|
||||
}, [state, listeningInput, thinkingInput, speakingInput, asleepInput]);
|
||||
|
||||
const Component = source.hasModel ? PersonaWithModel : PersonaWithoutModel;
|
||||
|
||||
return (
|
||||
<Component rive={rive} source={source}>
|
||||
<RiveComponent className={cn("size-16 shrink-0", className)} />
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Persona.displayName = "Persona";
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronsUpDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
import { Shimmer } from "./shimmer";
|
||||
|
||||
interface PlanContextValue {
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
const PlanContext = createContext<PlanContextValue | null>(null);
|
||||
|
||||
const usePlan = () => {
|
||||
const context = useContext(PlanContext);
|
||||
if (!context) {
|
||||
throw new Error("Plan components must be used within Plan");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type PlanProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean;
|
||||
};
|
||||
|
||||
export const Plan = ({
|
||||
className,
|
||||
isStreaming = false,
|
||||
children,
|
||||
...props
|
||||
}: PlanProps) => {
|
||||
const contextValue = useMemo(() => ({ isStreaming }), [isStreaming]);
|
||||
|
||||
return (
|
||||
<PlanContext.Provider value={contextValue}>
|
||||
<Collapsible data-slot="plan" {...props} render={<Card className={cn("shadow-none", className)} />}>{children}</Collapsible>
|
||||
</PlanContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type PlanHeaderProps = ComponentProps<typeof CardHeader>;
|
||||
|
||||
export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
|
||||
<CardHeader
|
||||
className={cn("flex items-start justify-between", className)}
|
||||
data-slot="plan-header"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type PlanTitleProps = Omit<
|
||||
ComponentProps<typeof CardTitle>,
|
||||
"children"
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
|
||||
const { isStreaming } = usePlan();
|
||||
|
||||
return (
|
||||
<CardTitle data-slot="plan-title" {...props}>
|
||||
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
||||
</CardTitle>
|
||||
);
|
||||
};
|
||||
|
||||
export type PlanDescriptionProps = Omit<
|
||||
ComponentProps<typeof CardDescription>,
|
||||
"children"
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const PlanDescription = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PlanDescriptionProps) => {
|
||||
const { isStreaming } = usePlan();
|
||||
|
||||
return (
|
||||
<CardDescription
|
||||
className={cn("text-balance", className)}
|
||||
data-slot="plan-description"
|
||||
{...props}
|
||||
>
|
||||
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
||||
</CardDescription>
|
||||
);
|
||||
};
|
||||
|
||||
export type PlanActionProps = ComponentProps<typeof CardAction>;
|
||||
|
||||
export const PlanAction = (props: PlanActionProps) => (
|
||||
<CardAction data-slot="plan-action" {...props} />
|
||||
);
|
||||
|
||||
export type PlanContentProps = ComponentProps<typeof CardContent>;
|
||||
|
||||
export const PlanContent = (props: PlanContentProps) => (
|
||||
<CollapsibleContent render={<CardContent data-slot="plan-content" {...props} />}></CollapsibleContent>
|
||||
);
|
||||
|
||||
export type PlanFooterProps = ComponentProps<"div">;
|
||||
|
||||
export const PlanFooter = (props: PlanFooterProps) => (
|
||||
<CardFooter data-slot="plan-footer" {...props} />
|
||||
);
|
||||
|
||||
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
|
||||
<CollapsibleTrigger render={<Button className={cn("size-8", className)} data-slot="plan-trigger" size="icon" variant="ghost" {...props} />}><ChevronsUpDownIcon className="size-4" /><span className="sr-only">Toggle plan</span></CollapsibleTrigger>
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon, PaperclipIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export interface QueueMessagePart {
|
||||
type: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
filename?: string;
|
||||
mediaType?: string;
|
||||
}
|
||||
|
||||
export interface QueueMessage {
|
||||
id: string;
|
||||
parts: QueueMessagePart[];
|
||||
}
|
||||
|
||||
export interface QueueTodo {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: "pending" | "completed";
|
||||
}
|
||||
|
||||
export type QueueItemProps = ComponentProps<"li">;
|
||||
|
||||
export const QueueItem = ({ className, ...props }: QueueItemProps) => (
|
||||
<li
|
||||
className={cn(
|
||||
"group flex flex-col gap-1 rounded-md px-3 py-1 text-sm transition-colors hover:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemIndicatorProps = ComponentProps<"span"> & {
|
||||
completed?: boolean;
|
||||
};
|
||||
|
||||
export const QueueItemIndicator = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemIndicatorProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 inline-block size-2.5 rounded-full border",
|
||||
completed
|
||||
? "border-muted-foreground/20 bg-muted-foreground/10"
|
||||
: "border-muted-foreground/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemContentProps = ComponentProps<"span"> & {
|
||||
completed?: boolean;
|
||||
};
|
||||
|
||||
export const QueueItemContent = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemContentProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"line-clamp-1 grow break-words",
|
||||
completed
|
||||
? "text-muted-foreground/50 line-through"
|
||||
: "text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemDescriptionProps = ComponentProps<"div"> & {
|
||||
completed?: boolean;
|
||||
};
|
||||
|
||||
export const QueueItemDescription = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemDescriptionProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"ml-6 text-xs",
|
||||
completed
|
||||
? "text-muted-foreground/40 line-through"
|
||||
: "text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const QueueItemActions = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemActionsProps) => (
|
||||
<div className={cn("flex gap-1", className)} {...props} />
|
||||
);
|
||||
|
||||
export type QueueItemActionProps = Omit<
|
||||
ComponentProps<typeof Button>,
|
||||
"variant" | "size"
|
||||
>;
|
||||
|
||||
export const QueueItemAction = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemActionProps) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"size-auto rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-muted-foreground/10 hover:text-foreground group-hover:opacity-100",
|
||||
className
|
||||
)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemAttachmentProps = ComponentProps<"div">;
|
||||
|
||||
export const QueueItemAttachment = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemAttachmentProps) => (
|
||||
<div className={cn("mt-1 flex flex-wrap gap-2", className)} {...props} />
|
||||
);
|
||||
|
||||
export type QueueItemImageProps = ComponentProps<"img">;
|
||||
|
||||
export const QueueItemImage = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemImageProps) => (
|
||||
<img
|
||||
alt=""
|
||||
className={cn("h-8 w-8 rounded border object-cover", className)}
|
||||
height={32}
|
||||
width={32}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemFileProps = ComponentProps<"span">;
|
||||
|
||||
export const QueueItemFile = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemFileProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded border bg-muted px-2 py-1 text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<PaperclipIcon size={12} />
|
||||
<span className="max-w-[100px] truncate">{children}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
export type QueueListProps = ComponentProps<typeof ScrollArea>;
|
||||
|
||||
export const QueueList = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueListProps) => (
|
||||
<ScrollArea className={cn("mt-2 -mb-1", className)} {...props}>
|
||||
<div className="max-h-40 pr-4">
|
||||
<ul>{children}</ul>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
// QueueSection - collapsible section container
|
||||
export type QueueSectionProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const QueueSection = ({
|
||||
className,
|
||||
defaultOpen = true,
|
||||
...props
|
||||
}: QueueSectionProps) => (
|
||||
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
|
||||
);
|
||||
|
||||
// QueueSectionTrigger - section header/trigger
|
||||
export type QueueSectionTriggerProps = ComponentProps<"button">;
|
||||
|
||||
export const QueueSectionTrigger = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionTriggerProps) => (
|
||||
<CollapsibleTrigger render={<button className={cn(
|
||||
"group flex w-full items-center justify-between rounded-md bg-muted/40 px-3 py-2 text-left font-medium text-muted-foreground text-sm transition-colors hover:bg-muted",
|
||||
className
|
||||
)} type="button" {...props} />}>{children}</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
// QueueSectionLabel - label content with icon and count
|
||||
export type QueueSectionLabelProps = ComponentProps<"span"> & {
|
||||
count?: number;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const QueueSectionLabel = ({
|
||||
count,
|
||||
label,
|
||||
icon,
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionLabelProps) => (
|
||||
<span className={cn("flex items-center gap-2", className)} {...props}>
|
||||
<ChevronDownIcon className="size-4 transition-transform group-data-[state=closed]:-rotate-90" />
|
||||
{icon}
|
||||
<span>
|
||||
{count} {label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
// QueueSectionContent - collapsible content area
|
||||
export type QueueSectionContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
>;
|
||||
|
||||
export const QueueSectionContent = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionContentProps) => (
|
||||
<CollapsibleContent className={cn(className)} {...props} />
|
||||
);
|
||||
|
||||
export type QueueProps = ComponentProps<"div">;
|
||||
|
||||
export const Queue = ({ className, ...props }: QueueProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 rounded-xl border border-border bg-background px-3 pt-2 pb-2 shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cjk } from "@streamdown/cjk";
|
||||
import { code } from "@streamdown/code";
|
||||
import { math } from "@streamdown/math";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import { Shimmer } from "./shimmer";
|
||||
|
||||
interface ReasoningContextValue {
|
||||
isStreaming: boolean;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
duration: number | undefined;
|
||||
}
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||
|
||||
export const useReasoning = () => {
|
||||
const context = useContext(ReasoningContext);
|
||||
if (!context) {
|
||||
throw new Error("Reasoning components must be used within Reasoning");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000;
|
||||
const MS_IN_S = 1000;
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
|
||||
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
|
||||
const isExplicitlyClosed = defaultOpen === false;
|
||||
|
||||
const [isOpen, setIsOpen] = useControllableState<boolean>({
|
||||
defaultProp: resolvedDefaultOpen,
|
||||
onChange: onOpenChange,
|
||||
prop: open,
|
||||
});
|
||||
const [duration, setDuration] = useControllableState<number | undefined>({
|
||||
defaultProp: undefined,
|
||||
prop: durationProp,
|
||||
});
|
||||
|
||||
const hasEverStreamedRef = useRef(isStreaming);
|
||||
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
|
||||
// Track when streaming starts and compute duration
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
hasEverStreamedRef.current = true;
|
||||
if (startTimeRef.current === null) {
|
||||
startTimeRef.current = Date.now();
|
||||
}
|
||||
} else if (startTimeRef.current !== null) {
|
||||
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
|
||||
startTimeRef.current = null;
|
||||
}
|
||||
}, [isStreaming, setDuration]);
|
||||
|
||||
// Auto-open when streaming starts (unless explicitly closed)
|
||||
useEffect(() => {
|
||||
if (isStreaming && !isOpen && !isExplicitlyClosed) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
|
||||
|
||||
// Auto-close when streaming ends (once only, and only if it ever streamed)
|
||||
useEffect(() => {
|
||||
if (
|
||||
hasEverStreamedRef.current &&
|
||||
!isStreaming &&
|
||||
isOpen &&
|
||||
!hasAutoClosed
|
||||
) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setHasAutoClosed(true);
|
||||
}, AUTO_CLOSE_DELAY);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
setIsOpen(newOpen);
|
||||
},
|
||||
[setIsOpen]
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ duration, isOpen, isStreaming, setIsOpen }),
|
||||
[duration, isOpen, isStreaming, setIsOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider value={contextValue}>
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4", className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</ReasoningContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
> & {
|
||||
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
|
||||
};
|
||||
|
||||
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
if (isStreaming || duration === 0) {
|
||||
return <Shimmer duration={1}>Thinking...</Shimmer>;
|
||||
}
|
||||
if (duration === undefined) {
|
||||
return <p>Thought for a few seconds</p>;
|
||||
}
|
||||
return <p>Thought for {duration} seconds</p>;
|
||||
};
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({
|
||||
className,
|
||||
children,
|
||||
getThinkingMessage = defaultGetThinkingMessage,
|
||||
...props
|
||||
}: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning();
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
const streamdownPlugins = { cjk, code, math, mermaid };
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-4 text-sm",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Streamdown plugins={streamdownPlugins}>{children}</Streamdown>
|
||||
</CollapsibleContent>
|
||||
)
|
||||
);
|
||||
|
||||
Reasoning.displayName = "Reasoning";
|
||||
ReasoningTrigger.displayName = "ReasoningTrigger";
|
||||
ReasoningContent.displayName = "ReasoningContent";
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import { ChevronDownIcon, Code } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { getStatusBadge } from "./tool";
|
||||
|
||||
export type SandboxRootProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Sandbox = ({ className, ...props }: SandboxRootProps) => (
|
||||
<Collapsible
|
||||
className={cn(
|
||||
"not-prose group mb-4 w-full overflow-hidden rounded-md border",
|
||||
className
|
||||
)}
|
||||
defaultOpen
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export interface SandboxHeaderProps {
|
||||
title?: string;
|
||||
state: ToolUIPart["state"];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const SandboxHeader = ({
|
||||
className,
|
||||
title,
|
||||
state,
|
||||
...props
|
||||
}: SandboxHeaderProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-4 p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">{title}</span>
|
||||
{getStatusBadge(state)}
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type SandboxContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const SandboxContent = ({
|
||||
className,
|
||||
...props
|
||||
}: SandboxContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SandboxTabsProps = ComponentProps<typeof Tabs>;
|
||||
|
||||
export const SandboxTabs = ({ className, ...props }: SandboxTabsProps) => (
|
||||
<Tabs className={cn("w-full gap-0", className)} {...props} />
|
||||
);
|
||||
|
||||
export type SandboxTabsBarProps = ComponentProps<"div">;
|
||||
|
||||
export const SandboxTabsBar = ({
|
||||
className,
|
||||
...props
|
||||
}: SandboxTabsBarProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center border-border border-t border-b",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SandboxTabsListProps = ComponentProps<typeof TabsList>;
|
||||
|
||||
export const SandboxTabsList = ({
|
||||
className,
|
||||
...props
|
||||
}: SandboxTabsListProps) => (
|
||||
<TabsList
|
||||
className={cn("h-auto rounded-none border-0 bg-transparent p-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SandboxTabsTriggerProps = ComponentProps<typeof TabsTrigger>;
|
||||
|
||||
export const SandboxTabsTrigger = ({
|
||||
className,
|
||||
...props
|
||||
}: SandboxTabsTriggerProps) => (
|
||||
<TabsTrigger
|
||||
className={cn(
|
||||
"rounded-none border-0 border-transparent border-b-2 px-4 py-2 font-medium text-muted-foreground text-sm transition-colors data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SandboxTabContentProps = ComponentProps<typeof TabsContent>;
|
||||
|
||||
export const SandboxTabContent = ({
|
||||
className,
|
||||
...props
|
||||
}: SandboxTabContentProps) => (
|
||||
<TabsContent className={cn("mt-0 text-sm", className)} {...props} />
|
||||
);
|
||||
@@ -0,0 +1,471 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
interface SchemaParameter {
|
||||
name: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
location?: "path" | "query" | "header";
|
||||
}
|
||||
|
||||
interface SchemaProperty {
|
||||
name: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
properties?: SchemaProperty[];
|
||||
items?: SchemaProperty;
|
||||
}
|
||||
|
||||
interface SchemaDisplayContextType {
|
||||
method: HttpMethod;
|
||||
path: string;
|
||||
description?: string;
|
||||
parameters?: SchemaParameter[];
|
||||
requestBody?: SchemaProperty[];
|
||||
responseBody?: SchemaProperty[];
|
||||
}
|
||||
|
||||
const SchemaDisplayContext = createContext<SchemaDisplayContextType>({
|
||||
method: "GET",
|
||||
path: "",
|
||||
});
|
||||
|
||||
const methodStyles: Record<HttpMethod, string> = {
|
||||
DELETE: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
|
||||
GET: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||
PATCH:
|
||||
"bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
|
||||
POST: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
||||
PUT: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400",
|
||||
};
|
||||
|
||||
export type SchemaDisplayHeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const SchemaDisplayHeader = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayHeaderProps) => (
|
||||
<div
|
||||
className={cn("flex items-center gap-3 border-b px-4 py-3", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type SchemaDisplayMethodProps = ComponentProps<typeof Badge>;
|
||||
|
||||
export const SchemaDisplayMethod = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayMethodProps) => {
|
||||
const { method } = useContext(SchemaDisplayContext);
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className={cn("font-mono text-xs", methodStyles[method], className)}
|
||||
variant="secondary"
|
||||
{...props}
|
||||
>
|
||||
{children ?? method}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaDisplayPathProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const SchemaDisplayPath = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayPathProps) => {
|
||||
const { path } = useContext(SchemaDisplayContext);
|
||||
|
||||
// Highlight path parameters
|
||||
const highlightedPath = path.replaceAll(
|
||||
/\{([^}]+)\}/g,
|
||||
'<span class="text-blue-600 dark:text-blue-400">{$1}</span>'
|
||||
);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("font-mono text-sm", className)}
|
||||
// oxlint-disable-next-line eslint-plugin-react(no-danger)
|
||||
dangerouslySetInnerHTML={{ __html: children ?? highlightedPath }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaDisplayDescriptionProps =
|
||||
HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const SchemaDisplayDescription = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayDescriptionProps) => {
|
||||
const { description } = useContext(SchemaDisplayContext);
|
||||
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
"border-b px-4 py-3 text-muted-foreground text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? description}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaDisplayContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const SchemaDisplayContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayContentProps) => (
|
||||
<div className={cn("divide-y", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type SchemaDisplayParameterProps = HTMLAttributes<HTMLDivElement> &
|
||||
SchemaParameter;
|
||||
|
||||
export const SchemaDisplayParameter = ({
|
||||
name,
|
||||
type,
|
||||
required,
|
||||
description,
|
||||
location,
|
||||
className,
|
||||
...props
|
||||
}: SchemaDisplayParameterProps) => (
|
||||
<div className={cn("px-4 py-3 pl-10", className)} {...props}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm">{name}</span>
|
||||
<Badge className="text-xs" variant="outline">
|
||||
{type}
|
||||
</Badge>
|
||||
{location && (
|
||||
<Badge className="text-xs" variant="secondary">
|
||||
{location}
|
||||
</Badge>
|
||||
)}
|
||||
{required && (
|
||||
<Badge
|
||||
className="bg-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400"
|
||||
variant="secondary"
|
||||
>
|
||||
required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="mt-1 text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type SchemaDisplayParametersProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const SchemaDisplayParameters = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayParametersProps) => {
|
||||
const { parameters } = useContext(SchemaDisplayContext);
|
||||
|
||||
return (
|
||||
<Collapsible className={cn(className)} defaultOpen {...props}>
|
||||
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/50">
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
||||
<span className="font-medium text-sm">Parameters</span>
|
||||
<Badge className="ml-auto text-xs" variant="secondary">
|
||||
{parameters?.length}
|
||||
</Badge>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="divide-y border-t">
|
||||
{children ??
|
||||
parameters?.map((param) => (
|
||||
<SchemaDisplayParameter key={param.name} {...param} />
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaDisplayPropertyProps = HTMLAttributes<HTMLDivElement> &
|
||||
SchemaProperty & {
|
||||
depth?: number;
|
||||
};
|
||||
|
||||
export const SchemaDisplayProperty = ({
|
||||
name,
|
||||
type,
|
||||
required,
|
||||
description,
|
||||
properties,
|
||||
items,
|
||||
depth = 0,
|
||||
className,
|
||||
...props
|
||||
}: SchemaDisplayPropertyProps) => {
|
||||
const hasChildren = properties || items;
|
||||
const paddingLeft = 40 + depth * 16;
|
||||
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<Collapsible defaultOpen={depth < 2}>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-2 py-3 text-left transition-colors hover:bg-muted/50",
|
||||
className
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
>
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
||||
<span className="font-mono text-sm">{name}</span>
|
||||
<Badge className="text-xs" variant="outline">
|
||||
{type}
|
||||
</Badge>
|
||||
{required && (
|
||||
<Badge
|
||||
className="bg-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400"
|
||||
variant="secondary"
|
||||
>
|
||||
required
|
||||
</Badge>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
{description && (
|
||||
<p
|
||||
className="pb-2 text-muted-foreground text-sm"
|
||||
style={{ paddingLeft: paddingLeft + 24 }}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
<CollapsibleContent>
|
||||
<div className="divide-y border-t">
|
||||
{properties?.map((prop) => (
|
||||
<SchemaDisplayProperty
|
||||
key={prop.name}
|
||||
{...prop}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
{items && (
|
||||
<SchemaDisplayProperty
|
||||
{...items}
|
||||
depth={depth + 1}
|
||||
name={`${name}[]`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("py-3 pr-4", className)}
|
||||
style={{ paddingLeft }}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Spacer for alignment */}
|
||||
<span className="size-4" />
|
||||
<span className="font-mono text-sm">{name}</span>
|
||||
<Badge className="text-xs" variant="outline">
|
||||
{type}
|
||||
</Badge>
|
||||
{required && (
|
||||
<Badge
|
||||
className="bg-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400"
|
||||
variant="secondary"
|
||||
>
|
||||
required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="mt-1 pl-6 text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaDisplayRequestProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const SchemaDisplayRequest = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayRequestProps) => {
|
||||
const { requestBody } = useContext(SchemaDisplayContext);
|
||||
|
||||
return (
|
||||
<Collapsible className={cn(className)} defaultOpen {...props}>
|
||||
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/50">
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
||||
<span className="font-medium text-sm">Request Body</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-t">
|
||||
{children ??
|
||||
requestBody?.map((prop) => (
|
||||
<SchemaDisplayProperty key={prop.name} {...prop} depth={0} />
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaDisplayResponseProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const SchemaDisplayResponse = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayResponseProps) => {
|
||||
const { responseBody } = useContext(SchemaDisplayContext);
|
||||
|
||||
return (
|
||||
<Collapsible className={cn(className)} defaultOpen {...props}>
|
||||
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/50">
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
||||
<span className="font-medium text-sm">Response</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-t">
|
||||
{children ??
|
||||
responseBody?.map((prop) => (
|
||||
<SchemaDisplayProperty key={prop.name} {...prop} depth={0} />
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaDisplayProps = HTMLAttributes<HTMLDivElement> & {
|
||||
method: HttpMethod;
|
||||
path: string;
|
||||
description?: string;
|
||||
parameters?: SchemaParameter[];
|
||||
requestBody?: SchemaProperty[];
|
||||
responseBody?: SchemaProperty[];
|
||||
};
|
||||
|
||||
export const SchemaDisplay = ({
|
||||
method,
|
||||
path,
|
||||
description,
|
||||
parameters,
|
||||
requestBody,
|
||||
responseBody,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayProps) => {
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
description,
|
||||
method,
|
||||
parameters,
|
||||
path,
|
||||
requestBody,
|
||||
responseBody,
|
||||
}),
|
||||
[description, method, parameters, path, requestBody, responseBody]
|
||||
);
|
||||
|
||||
return (
|
||||
<SchemaDisplayContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-lg border bg-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<SchemaDisplayHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<SchemaDisplayMethod />
|
||||
<SchemaDisplayPath />
|
||||
</div>
|
||||
</SchemaDisplayHeader>
|
||||
{description && <SchemaDisplayDescription />}
|
||||
<SchemaDisplayContent>
|
||||
{parameters && parameters.length > 0 && (
|
||||
<SchemaDisplayParameters />
|
||||
)}
|
||||
{requestBody && requestBody.length > 0 && (
|
||||
<SchemaDisplayRequest />
|
||||
)}
|
||||
{responseBody && responseBody.length > 0 && (
|
||||
<SchemaDisplayResponse />
|
||||
)}
|
||||
</SchemaDisplayContent>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SchemaDisplayContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaDisplayBodyProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const SchemaDisplayBody = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayBodyProps) => (
|
||||
<div className={cn("divide-y", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type SchemaDisplayExampleProps = HTMLAttributes<HTMLPreElement>;
|
||||
|
||||
export const SchemaDisplayExample = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayExampleProps) => (
|
||||
<pre
|
||||
className={cn(
|
||||
"mx-4 mb-4 overflow-auto rounded-md bg-muted p-4 font-mono text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MotionProps } from "motion/react";
|
||||
import { motion } from "motion/react";
|
||||
import type { CSSProperties, ElementType, JSX } from "react";
|
||||
import { memo, useMemo } from "react";
|
||||
|
||||
type MotionHTMLProps = MotionProps & Record<string, unknown>;
|
||||
|
||||
// Cache motion components at module level to avoid creating during render
|
||||
const motionComponentCache = new Map<
|
||||
keyof JSX.IntrinsicElements,
|
||||
React.ComponentType<MotionHTMLProps>
|
||||
>();
|
||||
|
||||
const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
|
||||
let component = motionComponentCache.get(element);
|
||||
if (!component) {
|
||||
component = motion.create(element);
|
||||
motionComponentCache.set(element, component);
|
||||
}
|
||||
return component;
|
||||
};
|
||||
|
||||
export interface TextShimmerProps {
|
||||
children: string;
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
spread?: number;
|
||||
}
|
||||
|
||||
const ShimmerComponent = ({
|
||||
children,
|
||||
as: Component = "p",
|
||||
className,
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
}: TextShimmerProps) => {
|
||||
const MotionComponent = getMotionComponent(
|
||||
Component as keyof JSX.IntrinsicElements
|
||||
);
|
||||
|
||||
const dynamicSpread = useMemo(
|
||||
() => (children?.length ?? 0) * spread,
|
||||
[children, spread]
|
||||
);
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
animate={{ backgroundPosition: "0% center" }}
|
||||
className={cn(
|
||||
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
||||
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
|
||||
className
|
||||
)}
|
||||
initial={{ backgroundPosition: "100% center" }}
|
||||
style={
|
||||
{
|
||||
"--spread": `${dynamicSpread}px`,
|
||||
backgroundImage:
|
||||
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
||||
} as CSSProperties
|
||||
}
|
||||
transition={{
|
||||
duration,
|
||||
ease: "linear",
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MotionComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export const Shimmer = memo(ShimmerComponent);
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@/components/ui/input-group";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface SnippetContextType {
|
||||
code: string;
|
||||
}
|
||||
|
||||
const SnippetContext = createContext<SnippetContextType>({
|
||||
code: "",
|
||||
});
|
||||
|
||||
export type SnippetProps = ComponentProps<typeof InputGroup> & {
|
||||
code: string;
|
||||
};
|
||||
|
||||
export const Snippet = ({
|
||||
code,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SnippetProps) => {
|
||||
const contextValue = useMemo(() => ({ code }), [code]);
|
||||
|
||||
return (
|
||||
<SnippetContext.Provider value={contextValue}>
|
||||
<InputGroup className={cn("font-mono", className)} {...props}>
|
||||
{children}
|
||||
</InputGroup>
|
||||
</SnippetContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type SnippetAddonProps = ComponentProps<typeof InputGroupAddon>;
|
||||
|
||||
export const SnippetAddon = (props: SnippetAddonProps) => (
|
||||
<InputGroupAddon {...props} />
|
||||
);
|
||||
|
||||
export type SnippetTextProps = ComponentProps<typeof InputGroupText>;
|
||||
|
||||
export const SnippetText = ({ className, ...props }: SnippetTextProps) => (
|
||||
<InputGroupText
|
||||
className={cn("pl-2 font-normal text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SnippetInputProps = Omit<
|
||||
ComponentProps<typeof InputGroupInput>,
|
||||
"readOnly" | "value"
|
||||
>;
|
||||
|
||||
export const SnippetInput = ({ className, ...props }: SnippetInputProps) => {
|
||||
const { code } = useContext(SnippetContext);
|
||||
|
||||
return (
|
||||
<InputGroupInput
|
||||
className={cn("text-foreground", className)}
|
||||
readOnly
|
||||
value={code}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type SnippetCopyButtonProps = ComponentProps<typeof InputGroupButton> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const SnippetCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: SnippetCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<number>(0);
|
||||
const { code } = useContext(SnippetContext);
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isCopied) {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
timeoutRef.current = window.setTimeout(
|
||||
() => setIsCopied(false),
|
||||
timeout
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}, [code, onCopy, onError, timeout, isCopied]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<InputGroupButton
|
||||
aria-label="Copy"
|
||||
className={className}
|
||||
onClick={copyToClipboard}
|
||||
size="icon-sm"
|
||||
title="Copy"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon className="size-3.5" size={14} />}
|
||||
</InputGroupButton>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BookIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type SourcesProps = ComponentProps<"div">;
|
||||
|
||||
export const Sources = ({ className, ...props }: SourcesProps) => (
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4 text-primary text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
count: number;
|
||||
};
|
||||
|
||||
export const SourcesTrigger = ({
|
||||
className,
|
||||
count,
|
||||
children,
|
||||
...props
|
||||
}: SourcesTriggerProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<p className="font-medium">Used {count} sources</p>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const SourcesContent = ({
|
||||
className,
|
||||
...props
|
||||
}: SourcesContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-3 flex w-fit flex-col gap-2",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SourceProps = ComponentProps<"a">;
|
||||
|
||||
export const Source = ({ href, title, children, ...props }: SourceProps) => (
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BookIcon className="h-4 w-4" />
|
||||
<span className="block font-medium">{title}</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
@@ -0,0 +1,323 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MicIcon, SquareIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface SpeechRecognition extends EventTarget {
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
lang: string;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
onstart: ((this: SpeechRecognition, ev: Event) => void) | null;
|
||||
onend: ((this: SpeechRecognition, ev: Event) => void) | null;
|
||||
onresult:
|
||||
| ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void)
|
||||
| null;
|
||||
onerror:
|
||||
| ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void)
|
||||
| null;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionEvent extends Event {
|
||||
results: SpeechRecognitionResultList;
|
||||
resultIndex: number;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionResultList {
|
||||
readonly length: number;
|
||||
item(index: number): SpeechRecognitionResult;
|
||||
[index: number]: SpeechRecognitionResult;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionResult {
|
||||
readonly length: number;
|
||||
item(index: number): SpeechRecognitionAlternative;
|
||||
[index: number]: SpeechRecognitionAlternative;
|
||||
isFinal: boolean;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionAlternative {
|
||||
transcript: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionErrorEvent extends Event {
|
||||
error: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
SpeechRecognition: new () => SpeechRecognition;
|
||||
webkitSpeechRecognition: new () => SpeechRecognition;
|
||||
}
|
||||
}
|
||||
|
||||
type SpeechInputMode = "speech-recognition" | "media-recorder" | "none";
|
||||
|
||||
export type SpeechInputProps = ComponentProps<typeof Button> & {
|
||||
onTranscriptionChange?: (text: string) => void;
|
||||
/**
|
||||
* Callback for when audio is recorded using MediaRecorder fallback.
|
||||
* This is called in browsers that don't support the Web Speech API (Firefox, Safari).
|
||||
* The callback receives an audio Blob that should be sent to a transcription service.
|
||||
* Return the transcribed text, which will be passed to onTranscriptionChange.
|
||||
*/
|
||||
onAudioRecorded?: (audioBlob: Blob) => Promise<string>;
|
||||
lang?: string;
|
||||
};
|
||||
|
||||
const detectSpeechInputMode = (): SpeechInputMode => {
|
||||
if (typeof window === "undefined") {
|
||||
return "none";
|
||||
}
|
||||
|
||||
if ("SpeechRecognition" in window || "webkitSpeechRecognition" in window) {
|
||||
return "speech-recognition";
|
||||
}
|
||||
|
||||
if ("MediaRecorder" in window && "mediaDevices" in navigator) {
|
||||
return "media-recorder";
|
||||
}
|
||||
|
||||
return "none";
|
||||
};
|
||||
|
||||
export const SpeechInput = ({
|
||||
className,
|
||||
onTranscriptionChange,
|
||||
onAudioRecorded,
|
||||
lang = "en-US",
|
||||
...props
|
||||
}: SpeechInputProps) => {
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [mode] = useState<SpeechInputMode>(detectSpeechInputMode);
|
||||
const [isRecognitionReady, setIsRecognitionReady] = useState(false);
|
||||
const recognitionRef = useRef<SpeechRecognition | null>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
const onTranscriptionChangeRef = useRef<
|
||||
SpeechInputProps["onTranscriptionChange"]
|
||||
>(onTranscriptionChange);
|
||||
const onAudioRecordedRef =
|
||||
useRef<SpeechInputProps["onAudioRecorded"]>(onAudioRecorded);
|
||||
|
||||
// Keep refs in sync
|
||||
onTranscriptionChangeRef.current = onTranscriptionChange;
|
||||
onAudioRecordedRef.current = onAudioRecorded;
|
||||
|
||||
// Initialize Speech Recognition when mode is speech-recognition
|
||||
useEffect(() => {
|
||||
if (mode !== "speech-recognition") {
|
||||
return;
|
||||
}
|
||||
|
||||
const SpeechRecognition =
|
||||
window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
const speechRecognition = new SpeechRecognition();
|
||||
|
||||
speechRecognition.continuous = true;
|
||||
speechRecognition.interimResults = true;
|
||||
speechRecognition.lang = lang;
|
||||
|
||||
const handleStart = () => {
|
||||
setIsListening(true);
|
||||
};
|
||||
|
||||
const handleEnd = () => {
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
const handleResult = (event: Event) => {
|
||||
const speechEvent = event as SpeechRecognitionEvent;
|
||||
let finalTranscript = "";
|
||||
|
||||
for (
|
||||
let i = speechEvent.resultIndex;
|
||||
i < speechEvent.results.length;
|
||||
i += 1
|
||||
) {
|
||||
const result = speechEvent.results[i];
|
||||
if (result.isFinal) {
|
||||
finalTranscript += result[0]?.transcript ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
if (finalTranscript) {
|
||||
onTranscriptionChangeRef.current?.(finalTranscript);
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
speechRecognition.addEventListener("start", handleStart);
|
||||
speechRecognition.addEventListener("end", handleEnd);
|
||||
speechRecognition.addEventListener("result", handleResult);
|
||||
speechRecognition.addEventListener("error", handleError);
|
||||
|
||||
recognitionRef.current = speechRecognition;
|
||||
setIsRecognitionReady(true);
|
||||
|
||||
return () => {
|
||||
speechRecognition.removeEventListener("start", handleStart);
|
||||
speechRecognition.removeEventListener("end", handleEnd);
|
||||
speechRecognition.removeEventListener("result", handleResult);
|
||||
speechRecognition.removeEventListener("error", handleError);
|
||||
speechRecognition.stop();
|
||||
recognitionRef.current = null;
|
||||
setIsRecognitionReady(false);
|
||||
};
|
||||
}, [mode, lang]);
|
||||
|
||||
// Cleanup MediaRecorder and stream on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (mediaRecorderRef.current?.state === "recording") {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
if (streamRef.current) {
|
||||
for (const track of streamRef.current.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Start MediaRecorder recording
|
||||
const startMediaRecorder = useCallback(async () => {
|
||||
if (!onAudioRecordedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
const mediaRecorder = new MediaRecorder(stream);
|
||||
audioChunksRef.current = [];
|
||||
|
||||
const handleDataAvailable = (event: BlobEvent) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
for (const track of stream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
streamRef.current = null;
|
||||
|
||||
const audioBlob = new Blob(audioChunksRef.current, {
|
||||
type: "audio/webm",
|
||||
});
|
||||
|
||||
if (audioBlob.size > 0 && onAudioRecordedRef.current) {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const transcript = await onAudioRecordedRef.current(audioBlob);
|
||||
if (transcript) {
|
||||
onTranscriptionChangeRef.current?.(transcript);
|
||||
}
|
||||
} catch {
|
||||
// Error handling delegated to the onAudioRecorded caller
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
setIsListening(false);
|
||||
for (const track of stream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
streamRef.current = null;
|
||||
};
|
||||
|
||||
mediaRecorder.addEventListener("dataavailable", handleDataAvailable);
|
||||
mediaRecorder.addEventListener("stop", handleStop);
|
||||
mediaRecorder.addEventListener("error", handleError);
|
||||
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
mediaRecorder.start();
|
||||
setIsListening(true);
|
||||
} catch {
|
||||
setIsListening(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Stop MediaRecorder recording
|
||||
const stopMediaRecorder = useCallback(() => {
|
||||
if (mediaRecorderRef.current?.state === "recording") {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
setIsListening(false);
|
||||
}, []);
|
||||
|
||||
const toggleListening = useCallback(() => {
|
||||
if (mode === "speech-recognition" && recognitionRef.current) {
|
||||
if (isListening) {
|
||||
recognitionRef.current.stop();
|
||||
} else {
|
||||
recognitionRef.current.start();
|
||||
}
|
||||
} else if (mode === "media-recorder") {
|
||||
if (isListening) {
|
||||
stopMediaRecorder();
|
||||
} else {
|
||||
startMediaRecorder();
|
||||
}
|
||||
}
|
||||
}, [mode, isListening, startMediaRecorder, stopMediaRecorder]);
|
||||
|
||||
// Determine if button should be disabled
|
||||
const isDisabled =
|
||||
mode === "none" ||
|
||||
(mode === "speech-recognition" && !isRecognitionReady) ||
|
||||
(mode === "media-recorder" && !onAudioRecorded) ||
|
||||
isProcessing;
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex items-center justify-center">
|
||||
{/* Animated pulse rings */}
|
||||
{isListening &&
|
||||
[0, 1, 2].map((index) => (
|
||||
<div
|
||||
className="absolute inset-0 animate-ping rounded-full border-2 border-red-400/30"
|
||||
key={index}
|
||||
style={{
|
||||
animationDelay: `${index * 0.3}s`,
|
||||
animationDuration: "2s",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Main record button */}
|
||||
<Button
|
||||
className={cn(
|
||||
"relative z-10 rounded-full transition-all duration-300",
|
||||
isListening
|
||||
? "bg-destructive text-white hover:bg-destructive/80 hover:text-white"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/80 hover:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
onClick={toggleListening}
|
||||
{...props}
|
||||
>
|
||||
{isProcessing && <Spinner />}
|
||||
{!isProcessing && isListening && <SquareIcon className="size-4" />}
|
||||
{!(isProcessing || isListening) && <MicIcon className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,522 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
CopyIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
// Regex patterns for parsing stack traces
|
||||
const STACK_FRAME_WITH_PARENS_REGEX = /^at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/;
|
||||
const STACK_FRAME_WITHOUT_FN_REGEX = /^at\s+(.+):(\d+):(\d+)$/;
|
||||
const ERROR_TYPE_REGEX = /^(\w+Error|Error):\s*(.*)$/;
|
||||
const AT_PREFIX_REGEX = /^at\s+/;
|
||||
|
||||
interface StackFrame {
|
||||
raw: string;
|
||||
functionName: string | null;
|
||||
filePath: string | null;
|
||||
lineNumber: number | null;
|
||||
columnNumber: number | null;
|
||||
isInternal: boolean;
|
||||
}
|
||||
|
||||
interface ParsedStackTrace {
|
||||
errorType: string | null;
|
||||
errorMessage: string;
|
||||
frames: StackFrame[];
|
||||
raw: string;
|
||||
}
|
||||
|
||||
interface StackTraceContextValue {
|
||||
trace: ParsedStackTrace;
|
||||
raw: string;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
onFilePathClick?: (filePath: string, line?: number, column?: number) => void;
|
||||
}
|
||||
|
||||
const StackTraceContext = createContext<StackTraceContextValue | null>(null);
|
||||
|
||||
const useStackTrace = () => {
|
||||
const context = useContext(StackTraceContext);
|
||||
if (!context) {
|
||||
throw new Error("StackTrace components must be used within StackTrace");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const parseStackFrame = (line: string): StackFrame => {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Pattern: at functionName (filePath:line:column)
|
||||
const withParensMatch = trimmed.match(STACK_FRAME_WITH_PARENS_REGEX);
|
||||
if (withParensMatch) {
|
||||
const [, functionName, filePath, lineNum, colNum] = withParensMatch;
|
||||
const isInternal =
|
||||
filePath.includes("node_modules") ||
|
||||
filePath.startsWith("node:") ||
|
||||
filePath.includes("internal/");
|
||||
return {
|
||||
columnNumber: colNum ? Number.parseInt(colNum, 10) : null,
|
||||
filePath: filePath ?? null,
|
||||
functionName: functionName ?? null,
|
||||
isInternal,
|
||||
lineNumber: lineNum ? Number.parseInt(lineNum, 10) : null,
|
||||
raw: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
// Pattern: at filePath:line:column (no function name)
|
||||
const withoutFnMatch = trimmed.match(STACK_FRAME_WITHOUT_FN_REGEX);
|
||||
if (withoutFnMatch) {
|
||||
const [, filePath, lineNum, colNum] = withoutFnMatch;
|
||||
const isInternal =
|
||||
(filePath?.includes("node_modules") ?? false) ||
|
||||
(filePath?.startsWith("node:") ?? false) ||
|
||||
(filePath?.includes("internal/") ?? false);
|
||||
return {
|
||||
columnNumber: colNum ? Number.parseInt(colNum, 10) : null,
|
||||
filePath: filePath ?? null,
|
||||
functionName: null,
|
||||
isInternal,
|
||||
lineNumber: lineNum ? Number.parseInt(lineNum, 10) : null,
|
||||
raw: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: unparseable line
|
||||
return {
|
||||
columnNumber: null,
|
||||
filePath: null,
|
||||
functionName: null,
|
||||
isInternal: trimmed.includes("node_modules") || trimmed.includes("node:"),
|
||||
lineNumber: null,
|
||||
raw: trimmed,
|
||||
};
|
||||
};
|
||||
|
||||
const parseStackTrace = (trace: string): ParsedStackTrace => {
|
||||
const lines = trace.split("\n").filter((line) => line.trim());
|
||||
|
||||
if (lines.length === 0) {
|
||||
return {
|
||||
errorMessage: trace,
|
||||
errorType: null,
|
||||
frames: [],
|
||||
raw: trace,
|
||||
};
|
||||
}
|
||||
|
||||
const firstLine = lines[0].trim();
|
||||
let errorType: string | null = null;
|
||||
let errorMessage = firstLine;
|
||||
|
||||
// Try to extract error type from "ErrorType: message" format
|
||||
const errorMatch = firstLine.match(ERROR_TYPE_REGEX);
|
||||
if (errorMatch) {
|
||||
const [, type, msg] = errorMatch;
|
||||
errorType = type;
|
||||
errorMessage = msg || "";
|
||||
}
|
||||
|
||||
// Parse stack frames (lines starting with "at")
|
||||
const frames = lines
|
||||
.slice(1)
|
||||
.filter((line) => line.trim().startsWith("at "))
|
||||
.map(parseStackFrame);
|
||||
|
||||
return {
|
||||
errorMessage,
|
||||
errorType,
|
||||
frames,
|
||||
raw: trace,
|
||||
};
|
||||
};
|
||||
|
||||
export type StackTraceProps = ComponentProps<"div"> & {
|
||||
trace: string;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onFilePathClick?: (filePath: string, line?: number, column?: number) => void;
|
||||
};
|
||||
|
||||
export const StackTrace = memo(
|
||||
({
|
||||
trace,
|
||||
className,
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
onFilePathClick,
|
||||
children,
|
||||
...props
|
||||
}: StackTraceProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
prop: open,
|
||||
});
|
||||
|
||||
const parsedTrace = useMemo(() => parseStackTrace(trace), [trace]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
isOpen,
|
||||
onFilePathClick,
|
||||
raw: trace,
|
||||
setIsOpen,
|
||||
trace: parsedTrace,
|
||||
}),
|
||||
[parsedTrace, trace, isOpen, setIsOpen, onFilePathClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<StackTraceContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"not-prose w-full overflow-hidden rounded-lg border bg-background font-mono text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</StackTraceContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type StackTraceHeaderProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
export const StackTraceHeader = memo(
|
||||
({ className, children, ...props }: StackTraceHeaderProps) => {
|
||||
const { isOpen, setIsOpen } = useStackTrace();
|
||||
|
||||
return (
|
||||
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
|
||||
<CollapsibleTrigger {...props} render={<div className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-3 p-3 text-left transition-colors hover:bg-muted/50",
|
||||
className
|
||||
)} />}>{children}</CollapsibleTrigger>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type StackTraceErrorProps = ComponentProps<"div">;
|
||||
|
||||
export const StackTraceError = memo(
|
||||
({ className, children, ...props }: StackTraceErrorProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 items-center gap-2 overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<AlertTriangleIcon className="size-4 shrink-0 text-destructive" />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
export type StackTraceErrorTypeProps = ComponentProps<"span">;
|
||||
|
||||
export const StackTraceErrorType = memo(
|
||||
({ className, children, ...props }: StackTraceErrorTypeProps) => {
|
||||
const { trace } = useStackTrace();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("shrink-0 font-semibold text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? trace.errorType}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type StackTraceErrorMessageProps = ComponentProps<"span">;
|
||||
|
||||
export const StackTraceErrorMessage = memo(
|
||||
({ className, children, ...props }: StackTraceErrorMessageProps) => {
|
||||
const { trace } = useStackTrace();
|
||||
|
||||
return (
|
||||
<span className={cn("truncate text-foreground", className)} {...props}>
|
||||
{children ?? trace.errorMessage}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type StackTraceActionsProps = ComponentProps<"div">;
|
||||
|
||||
const handleActionsClick = (e: React.MouseEvent) => e.stopPropagation();
|
||||
const handleActionsKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
export const StackTraceActions = memo(
|
||||
({ className, children, ...props }: StackTraceActionsProps) => (
|
||||
<div
|
||||
className={cn("flex shrink-0 items-center gap-1", className)}
|
||||
onClick={handleActionsClick}
|
||||
onKeyDown={handleActionsKeyDown}
|
||||
role="group"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
export type StackTraceCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const StackTraceCopyButton = memo(
|
||||
({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: StackTraceCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<number>(0);
|
||||
const { raw } = useStackTrace();
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(raw);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
timeoutRef.current = window.setTimeout(
|
||||
() => setIsCopied(false),
|
||||
timeout
|
||||
);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}, [raw, onCopy, onError, timeout]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("size-7", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type StackTraceExpandButtonProps = ComponentProps<"div">;
|
||||
|
||||
export const StackTraceExpandButton = memo(
|
||||
({ className, ...props }: StackTraceExpandButtonProps) => {
|
||||
const { isOpen } = useStackTrace();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex size-7 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 text-muted-foreground transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type StackTraceContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
maxHeight?: number;
|
||||
};
|
||||
|
||||
export const StackTraceContent = memo(
|
||||
({
|
||||
className,
|
||||
maxHeight = 400,
|
||||
children,
|
||||
...props
|
||||
}: StackTraceContentProps) => {
|
||||
const { isOpen } = useStackTrace();
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen}>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"overflow-auto border-t bg-muted/30",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
style={{ maxHeight }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type StackTraceFramesProps = ComponentProps<"div"> & {
|
||||
showInternalFrames?: boolean;
|
||||
};
|
||||
|
||||
interface FilePathButtonProps {
|
||||
frame: StackFrame;
|
||||
onFilePathClick?: (
|
||||
filePath: string,
|
||||
lineNumber?: number,
|
||||
columnNumber?: number
|
||||
) => void;
|
||||
}
|
||||
|
||||
const FilePathButton = memo(
|
||||
({ frame, onFilePathClick }: FilePathButtonProps) => {
|
||||
const handleClick = useCallback(() => {
|
||||
if (frame.filePath) {
|
||||
onFilePathClick?.(
|
||||
frame.filePath,
|
||||
frame.lineNumber ?? undefined,
|
||||
frame.columnNumber ?? undefined
|
||||
);
|
||||
}
|
||||
}, [frame, onFilePathClick]);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"underline decoration-dotted hover:text-primary",
|
||||
onFilePathClick && "cursor-pointer"
|
||||
)}
|
||||
disabled={!onFilePathClick}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
>
|
||||
{frame.filePath}
|
||||
{frame.lineNumber !== null && `:${frame.lineNumber}`}
|
||||
{frame.columnNumber !== null && `:${frame.columnNumber}`}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FilePathButton.displayName = "FilePathButton";
|
||||
|
||||
export const StackTraceFrames = memo(
|
||||
({
|
||||
className,
|
||||
showInternalFrames = true,
|
||||
...props
|
||||
}: StackTraceFramesProps) => {
|
||||
const { trace, onFilePathClick } = useStackTrace();
|
||||
|
||||
const framesToShow = showInternalFrames
|
||||
? trace.frames
|
||||
: trace.frames.filter((f) => !f.isInternal);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-1 p-3", className)} {...props}>
|
||||
{framesToShow.map((frame) => (
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs",
|
||||
frame.isInternal
|
||||
? "text-muted-foreground/50"
|
||||
: "text-foreground/90"
|
||||
)}
|
||||
key={frame.raw}
|
||||
>
|
||||
<span className="text-muted-foreground">at </span>
|
||||
{frame.functionName && (
|
||||
<span className={frame.isInternal ? "" : "text-foreground"}>
|
||||
{frame.functionName}{" "}
|
||||
</span>
|
||||
)}
|
||||
{frame.filePath && (
|
||||
<>
|
||||
<span className="text-muted-foreground">(</span>
|
||||
<FilePathButton
|
||||
frame={frame}
|
||||
onFilePathClick={onFilePathClick}
|
||||
/>
|
||||
<span className="text-muted-foreground">)</span>
|
||||
</>
|
||||
)}
|
||||
{!(frame.filePath || frame.functionName) && (
|
||||
<span>{frame.raw.replace(AT_PREFIX_REGEX, "")}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{framesToShow.length === 0 && (
|
||||
<div className="text-muted-foreground text-xs">No stack frames</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
StackTrace.displayName = "StackTrace";
|
||||
StackTraceHeader.displayName = "StackTraceHeader";
|
||||
StackTraceError.displayName = "StackTraceError";
|
||||
StackTraceErrorType.displayName = "StackTraceErrorType";
|
||||
StackTraceErrorMessage.displayName = "StackTraceErrorMessage";
|
||||
StackTraceActions.displayName = "StackTraceActions";
|
||||
StackTraceCopyButton.displayName = "StackTraceCopyButton";
|
||||
StackTraceExpandButton.displayName = "StackTraceExpandButton";
|
||||
StackTraceContent.displayName = "StackTraceContent";
|
||||
StackTraceFrames.displayName = "StackTraceFrames";
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ScrollArea,
|
||||
ScrollBar,
|
||||
} from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
|
||||
|
||||
export const Suggestions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SuggestionsProps) => (
|
||||
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
|
||||
<div className={cn("flex w-max flex-nowrap items-center gap-2", className)}>
|
||||
{children}
|
||||
</div>
|
||||
<ScrollBar className="hidden" orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
|
||||
suggestion: string;
|
||||
onClick?: (suggestion: string) => void;
|
||||
};
|
||||
|
||||
export const Suggestion = ({
|
||||
suggestion,
|
||||
onClick,
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "sm",
|
||||
children,
|
||||
...props
|
||||
}: SuggestionProps) => {
|
||||
const handleClick = useCallback(() => {
|
||||
onClick?.(suggestion);
|
||||
}, [onClick, suggestion]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("cursor-pointer rounded-full px-4", className)}
|
||||
onClick={handleClick}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children || suggestion}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon, SearchIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type TaskItemFileProps = ComponentProps<"div">;
|
||||
|
||||
export const TaskItemFile = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TaskItemFileProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-foreground text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TaskItemProps = ComponentProps<"div">;
|
||||
|
||||
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
|
||||
<div className={cn("text-muted-foreground text-sm", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TaskProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Task = ({
|
||||
defaultOpen = true,
|
||||
className,
|
||||
...props
|
||||
}: TaskProps) => (
|
||||
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
|
||||
);
|
||||
|
||||
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const TaskTrigger = ({
|
||||
children,
|
||||
className,
|
||||
title,
|
||||
...props
|
||||
}: TaskTriggerProps) => (
|
||||
<CollapsibleTrigger className={cn("group", className)} {...props}>
|
||||
{children ?? (
|
||||
<div className="flex w-full cursor-pointer items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground">
|
||||
<SearchIcon className="size-4" />
|
||||
<p className="text-sm">{title}</p>
|
||||
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const TaskContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TaskContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mt-4 space-y-2 border-muted border-l-2 pl-4">
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
);
|
||||
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Ansi from "ansi-to-react";
|
||||
import { CheckIcon, CopyIcon, TerminalIcon, Trash2Icon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface TerminalContextType {
|
||||
output: string;
|
||||
isStreaming: boolean;
|
||||
autoScroll: boolean;
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
const TerminalContext = createContext<TerminalContextType>({
|
||||
autoScroll: true,
|
||||
isStreaming: false,
|
||||
output: "",
|
||||
});
|
||||
|
||||
export type TerminalHeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TerminalHeader = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TerminalHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-zinc-800 border-b px-4 py-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TerminalTitleProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TerminalTitle = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TerminalTitleProps) => (
|
||||
<div
|
||||
className={cn("flex items-center gap-2 text-sm text-zinc-400", className)}
|
||||
{...props}
|
||||
>
|
||||
<TerminalIcon className="size-4" />
|
||||
{children ?? "Terminal"}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TerminalStatusProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TerminalStatus = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TerminalStatusProps) => {
|
||||
const { isStreaming } = useContext(TerminalContext);
|
||||
|
||||
if (!isStreaming) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center gap-2 text-xs text-zinc-400", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type TerminalActionsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TerminalActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TerminalActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TerminalCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const TerminalCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TerminalCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<number>(0);
|
||||
const { output } = useContext(TerminalContext);
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(output);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}, [output, onCopy, onError, timeout]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"size-7 shrink-0 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100",
|
||||
className
|
||||
)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type TerminalClearButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const TerminalClearButton = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TerminalClearButtonProps) => {
|
||||
const { onClear } = useContext(TerminalContext);
|
||||
|
||||
if (!onClear) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"size-7 shrink-0 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100",
|
||||
className
|
||||
)}
|
||||
onClick={onClear}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Trash2Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type TerminalContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TerminalContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TerminalContentProps) => {
|
||||
const { output, isStreaming, autoScroll } = useContext(TerminalContext);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
}, [output, autoScroll]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"max-h-96 overflow-auto p-4 font-mono text-sm leading-relaxed",
|
||||
className
|
||||
)}
|
||||
ref={containerRef}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<pre className="whitespace-pre-wrap break-words">
|
||||
<Ansi>{output}</Ansi>
|
||||
{isStreaming && (
|
||||
<span className="ml-0.5 inline-block h-4 w-2 animate-pulse bg-zinc-100" />
|
||||
)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type TerminalProps = HTMLAttributes<HTMLDivElement> & {
|
||||
output: string;
|
||||
isStreaming?: boolean;
|
||||
autoScroll?: boolean;
|
||||
onClear?: () => void;
|
||||
};
|
||||
|
||||
export const Terminal = ({
|
||||
output,
|
||||
isStreaming = false,
|
||||
autoScroll = true,
|
||||
onClear,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TerminalProps) => {
|
||||
const contextValue = useMemo(
|
||||
() => ({ autoScroll, isStreaming, onClear, output }),
|
||||
[autoScroll, isStreaming, onClear, output]
|
||||
);
|
||||
|
||||
return (
|
||||
<TerminalContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col overflow-hidden rounded-lg border bg-zinc-950 text-zinc-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<TerminalHeader>
|
||||
<TerminalTitle />
|
||||
<div className="flex items-center gap-1">
|
||||
<TerminalStatus />
|
||||
<TerminalActions>
|
||||
<TerminalCopyButton />
|
||||
{onClear && <TerminalClearButton />}
|
||||
</TerminalActions>
|
||||
</div>
|
||||
</TerminalHeader>
|
||||
<TerminalContent />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TerminalContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,496 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
ChevronRightIcon,
|
||||
CircleDotIcon,
|
||||
CircleIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
type TestStatus = "passed" | "failed" | "skipped" | "running";
|
||||
|
||||
interface TestResultsSummary {
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface TestResultsContextType {
|
||||
summary?: TestResultsSummary;
|
||||
}
|
||||
|
||||
const TestResultsContext = createContext<TestResultsContextType>({});
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
}
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
};
|
||||
|
||||
export type TestResultsHeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TestResultsHeader = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestResultsHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b px-4 py-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TestResultsDurationProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const TestResultsDuration = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestResultsDurationProps) => {
|
||||
const { summary } = useContext(TestResultsContext);
|
||||
|
||||
if (!summary?.duration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn("text-muted-foreground text-sm", className)} {...props}>
|
||||
{children ?? formatDuration(summary.duration)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestResultsSummaryProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TestResultsSummary = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestResultsSummaryProps) => {
|
||||
const { summary } = useContext(TestResultsContext);
|
||||
|
||||
if (!summary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-3", className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<Badge
|
||||
className="gap-1 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||
variant="secondary"
|
||||
>
|
||||
<CheckCircle2Icon className="size-3" />
|
||||
{summary.passed} passed
|
||||
</Badge>
|
||||
{summary.failed > 0 && (
|
||||
<Badge
|
||||
className="gap-1 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
variant="secondary"
|
||||
>
|
||||
<XCircleIcon className="size-3" />
|
||||
{summary.failed} failed
|
||||
</Badge>
|
||||
)}
|
||||
{summary.skipped > 0 && (
|
||||
<Badge
|
||||
className="gap-1 bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
|
||||
variant="secondary"
|
||||
>
|
||||
<CircleIcon className="size-3" />
|
||||
{summary.skipped} skipped
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestResultsProps = HTMLAttributes<HTMLDivElement> & {
|
||||
summary?: TestResultsSummary;
|
||||
};
|
||||
|
||||
export const TestResults = ({
|
||||
summary,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestResultsProps) => {
|
||||
const contextValue = useMemo(() => ({ summary }), [summary]);
|
||||
|
||||
return (
|
||||
<TestResultsContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("rounded-lg border bg-background", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ??
|
||||
(summary && (
|
||||
<TestResultsHeader>
|
||||
<TestResultsSummary />
|
||||
<TestResultsDuration />
|
||||
</TestResultsHeader>
|
||||
))}
|
||||
</div>
|
||||
</TestResultsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestResultsProgressProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TestResultsProgress = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestResultsProgressProps) => {
|
||||
const { summary } = useContext(TestResultsContext);
|
||||
|
||||
if (!summary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const passedPercent = (summary.passed / summary.total) * 100;
|
||||
const failedPercent = (summary.failed / summary.total) * 100;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<div className="flex h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="bg-green-500 transition-all"
|
||||
style={{ width: `${passedPercent}%` }}
|
||||
/>
|
||||
<div
|
||||
className="bg-red-500 transition-all"
|
||||
style={{ width: `${failedPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-muted-foreground text-xs">
|
||||
<span>
|
||||
{summary.passed}/{summary.total} tests passed
|
||||
</span>
|
||||
<span>{passedPercent.toFixed(0)}%</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestResultsContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TestResultsContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestResultsContentProps) => (
|
||||
<div className={cn("space-y-2 p-4", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface TestSuiteContextType {
|
||||
name: string;
|
||||
status: TestStatus;
|
||||
}
|
||||
|
||||
const TestSuiteContext = createContext<TestSuiteContextType>({
|
||||
name: "",
|
||||
status: "passed",
|
||||
});
|
||||
|
||||
const statusStyles: Record<TestStatus, string> = {
|
||||
failed: "text-red-600 dark:text-red-400",
|
||||
passed: "text-green-600 dark:text-green-400",
|
||||
running: "text-blue-600 dark:text-blue-400",
|
||||
skipped: "text-yellow-600 dark:text-yellow-400",
|
||||
};
|
||||
|
||||
const statusIcons: Record<TestStatus, React.ReactNode> = {
|
||||
failed: <XCircleIcon className="size-4" />,
|
||||
passed: <CheckCircle2Icon className="size-4" />,
|
||||
running: <CircleDotIcon className="size-4 animate-pulse" />,
|
||||
skipped: <CircleIcon className="size-4" />,
|
||||
};
|
||||
|
||||
const TestStatusIcon = ({ status }: { status: TestStatus }) => (
|
||||
<span className={cn("shrink-0", statusStyles[status])}>
|
||||
{statusIcons[status]}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type TestSuiteProps = ComponentProps<typeof Collapsible> & {
|
||||
name: string;
|
||||
status: TestStatus;
|
||||
};
|
||||
|
||||
export const TestSuite = ({
|
||||
name,
|
||||
status,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestSuiteProps) => {
|
||||
const contextValue = useMemo(() => ({ name, status }), [name, status]);
|
||||
|
||||
return (
|
||||
<TestSuiteContext.Provider value={contextValue}>
|
||||
<Collapsible className={cn("rounded-lg border", className)} {...props}>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</TestSuiteContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestSuiteNameProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
export const TestSuiteName = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestSuiteNameProps) => {
|
||||
const { name, status } = useContext(TestSuiteContext);
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
||||
<TestStatusIcon status={status} />
|
||||
<span className="font-medium text-sm">{children ?? name}</span>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestSuiteStatsProps = HTMLAttributes<HTMLDivElement> & {
|
||||
passed?: number;
|
||||
failed?: number;
|
||||
skipped?: number;
|
||||
};
|
||||
|
||||
export const TestSuiteStats = ({
|
||||
passed = 0,
|
||||
failed = 0,
|
||||
skipped = 0,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestSuiteStatsProps) => (
|
||||
<div
|
||||
className={cn("ml-auto flex items-center gap-2 text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{passed > 0 && (
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
{passed} passed
|
||||
</span>
|
||||
)}
|
||||
{failed > 0 && (
|
||||
<span className="text-red-600 dark:text-red-400">
|
||||
{failed} failed
|
||||
</span>
|
||||
)}
|
||||
{skipped > 0 && (
|
||||
<span className="text-yellow-600 dark:text-yellow-400">
|
||||
{skipped} skipped
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TestSuiteContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const TestSuiteContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestSuiteContentProps) => (
|
||||
<CollapsibleContent className={cn("border-t", className)} {...props}>
|
||||
<div className="divide-y">{children}</div>
|
||||
</CollapsibleContent>
|
||||
);
|
||||
|
||||
interface TestContextType {
|
||||
name: string;
|
||||
status: TestStatus;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
const TestContext = createContext<TestContextType>({
|
||||
name: "",
|
||||
status: "passed",
|
||||
});
|
||||
|
||||
export type TestNameProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const TestName = ({ className, children, ...props }: TestNameProps) => {
|
||||
const { name } = useContext(TestContext);
|
||||
|
||||
return (
|
||||
<span className={cn("flex-1", className)} {...props}>
|
||||
{children ?? name}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestDurationProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const TestDuration = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestDurationProps) => {
|
||||
const { duration } = useContext(TestContext);
|
||||
|
||||
if (duration === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-muted-foreground text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? `${duration}ms`}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestStatusProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const TestStatus = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestStatusProps) => {
|
||||
const { status } = useContext(TestContext);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("shrink-0", statusStyles[status], className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? statusIcons[status]}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestProps = HTMLAttributes<HTMLDivElement> & {
|
||||
name: string;
|
||||
status: TestStatus;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
export const Test = ({
|
||||
name,
|
||||
status,
|
||||
duration,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestProps) => {
|
||||
const contextValue = useMemo(
|
||||
() => ({ duration, name, status }),
|
||||
[duration, name, status]
|
||||
);
|
||||
|
||||
return (
|
||||
<TestContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("flex items-center gap-2 px-4 py-2 text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<TestStatus />
|
||||
<TestName />
|
||||
{duration !== undefined && <TestDuration />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TestContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestErrorProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TestError = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestErrorProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-2 rounded-md bg-red-50 p-3 dark:bg-red-900/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TestErrorMessageProps = HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const TestErrorMessage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestErrorMessageProps) => (
|
||||
<p
|
||||
className={cn(
|
||||
"font-medium text-red-700 text-sm dark:text-red-400",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
export type TestErrorStackProps = HTMLAttributes<HTMLPreElement>;
|
||||
|
||||
export const TestErrorStack = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestErrorStackProps) => (
|
||||
<pre
|
||||
className={cn(
|
||||
"mt-2 overflow-auto font-mono text-red-600 text-xs dark:text-red-400",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DynamicToolUIPart, ToolUIPart } from "ai";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
CircleIcon,
|
||||
ClockIcon,
|
||||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { isValidElement } from "react";
|
||||
|
||||
import { CodeBlock } from "./code-block";
|
||||
|
||||
export type ToolProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Tool = ({ className, ...props }: ToolProps) => (
|
||||
<Collapsible
|
||||
className={cn("group not-prose mb-4 w-full rounded-md border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ToolPart = ToolUIPart | DynamicToolUIPart;
|
||||
|
||||
export type ToolHeaderProps = {
|
||||
title?: string;
|
||||
className?: string;
|
||||
} & (
|
||||
| { type: ToolUIPart["type"]; state: ToolUIPart["state"]; toolName?: never }
|
||||
| {
|
||||
type: DynamicToolUIPart["type"];
|
||||
state: DynamicToolUIPart["state"];
|
||||
toolName: string;
|
||||
}
|
||||
);
|
||||
|
||||
const statusLabels: Record<ToolPart["state"], string> = {
|
||||
"approval-requested": "Awaiting Approval",
|
||||
"approval-responded": "Responded",
|
||||
"input-available": "Running",
|
||||
"input-streaming": "Pending",
|
||||
"output-available": "Completed",
|
||||
"output-denied": "Denied",
|
||||
"output-error": "Error",
|
||||
};
|
||||
|
||||
const statusIcons: Record<ToolPart["state"], ReactNode> = {
|
||||
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
|
||||
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
|
||||
"input-available": <ClockIcon className="size-4 animate-pulse" />,
|
||||
"input-streaming": <CircleIcon className="size-4" />,
|
||||
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
|
||||
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
|
||||
"output-error": <XCircleIcon className="size-4 text-red-600" />,
|
||||
};
|
||||
|
||||
export const getStatusBadge = (status: ToolPart["state"]) => (
|
||||
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
|
||||
{statusIcons[status]}
|
||||
{statusLabels[status]}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
export const ToolHeader = ({
|
||||
className,
|
||||
title,
|
||||
type,
|
||||
state,
|
||||
toolName,
|
||||
...props
|
||||
}: ToolHeaderProps) => {
|
||||
const derivedName =
|
||||
type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-");
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-4 p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<WrenchIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">{title ?? derivedName}</span>
|
||||
{getStatusBadge(state)}
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 space-y-4 p-4 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ToolInputProps = ComponentProps<"div"> & {
|
||||
input: ToolPart["input"];
|
||||
};
|
||||
|
||||
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
|
||||
<div className={cn("space-y-2 overflow-hidden", className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Parameters
|
||||
</h4>
|
||||
<div className="rounded-md bg-muted/50">
|
||||
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ToolOutputProps = ComponentProps<"div"> & {
|
||||
output: ToolPart["output"];
|
||||
errorText: ToolPart["errorText"];
|
||||
};
|
||||
|
||||
export const ToolOutput = ({
|
||||
className,
|
||||
output,
|
||||
errorText,
|
||||
...props
|
||||
}: ToolOutputProps) => {
|
||||
if (!(output || errorText)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let Output = <div>{output as ReactNode}</div>;
|
||||
|
||||
if (typeof output === "object" && !isValidElement(output)) {
|
||||
Output = (
|
||||
<CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
|
||||
);
|
||||
} else if (typeof output === "string") {
|
||||
Output = <CodeBlock code={output} language="json" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{errorText ? "Error" : "Result"}
|
||||
</h4>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
|
||||
errorText
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-muted/50 text-foreground"
|
||||
)}
|
||||
>
|
||||
{errorText && <div>{errorText}</div>}
|
||||
{Output}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NodeToolbar, Position } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
type ToolbarProps = ComponentProps<typeof NodeToolbar>;
|
||||
|
||||
export const Toolbar = ({ className, ...props }: ToolbarProps) => (
|
||||
<NodeToolbar
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-sm border bg-background p-1.5",
|
||||
className
|
||||
)}
|
||||
position={Position.Bottom}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Experimental_TranscriptionResult as TranscriptionResult } from "ai";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useMemo } from "react";
|
||||
|
||||
type TranscriptionSegment = TranscriptionResult["segments"][number];
|
||||
|
||||
interface TranscriptionContextValue {
|
||||
segments: TranscriptionSegment[];
|
||||
currentTime: number;
|
||||
onTimeUpdate: (time: number) => void;
|
||||
onSeek?: (time: number) => void;
|
||||
}
|
||||
|
||||
const TranscriptionContext = createContext<TranscriptionContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const useTranscription = () => {
|
||||
const context = useContext(TranscriptionContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"Transcription components must be used within Transcription"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type TranscriptionProps = Omit<ComponentProps<"div">, "children"> & {
|
||||
segments: TranscriptionSegment[];
|
||||
currentTime?: number;
|
||||
onSeek?: (time: number) => void;
|
||||
children: (segment: TranscriptionSegment, index: number) => ReactNode;
|
||||
};
|
||||
|
||||
export const Transcription = ({
|
||||
segments,
|
||||
currentTime: externalCurrentTime,
|
||||
onSeek,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TranscriptionProps) => {
|
||||
const [currentTime, setCurrentTime] = useControllableState({
|
||||
defaultProp: 0,
|
||||
onChange: onSeek,
|
||||
prop: externalCurrentTime,
|
||||
});
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ currentTime, onSeek, onTimeUpdate: setCurrentTime, segments }),
|
||||
[currentTime, onSeek, setCurrentTime, segments]
|
||||
);
|
||||
|
||||
return (
|
||||
<TranscriptionContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap gap-1 text-sm leading-relaxed",
|
||||
className
|
||||
)}
|
||||
data-slot="transcription"
|
||||
{...props}
|
||||
>
|
||||
{segments
|
||||
.filter((segment) => segment.text.trim())
|
||||
.map((segment, index) => children(segment, index))}
|
||||
</div>
|
||||
</TranscriptionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type TranscriptionSegmentProps = ComponentProps<"button"> & {
|
||||
segment: TranscriptionSegment;
|
||||
index: number;
|
||||
};
|
||||
|
||||
export const TranscriptionSegment = ({
|
||||
segment,
|
||||
index,
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: TranscriptionSegmentProps) => {
|
||||
const { currentTime, onSeek } = useTranscription();
|
||||
|
||||
const isActive =
|
||||
currentTime >= segment.startSecond && currentTime < segment.endSecond;
|
||||
const isPast = currentTime >= segment.endSecond;
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (onSeek) {
|
||||
onSeek(segment.startSecond);
|
||||
}
|
||||
onClick?.(event);
|
||||
},
|
||||
[onSeek, segment.startSecond, onClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline text-left",
|
||||
isActive && "text-primary",
|
||||
isPast && "text-muted-foreground",
|
||||
!(isActive || isPast) && "text-muted-foreground/60",
|
||||
onSeek && "cursor-pointer hover:text-foreground",
|
||||
!onSeek && "cursor-default",
|
||||
className
|
||||
)}
|
||||
data-active={isActive}
|
||||
data-index={index}
|
||||
data-slot="transcription-segment"
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
{segment.text}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,524 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CircleSmallIcon,
|
||||
MarsIcon,
|
||||
MarsStrokeIcon,
|
||||
NonBinaryIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
TransgenderIcon,
|
||||
VenusAndMarsIcon,
|
||||
VenusIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useMemo } from "react";
|
||||
|
||||
interface VoiceSelectorContextValue {
|
||||
value: string | undefined;
|
||||
setValue: (value: string | undefined) => void;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const VoiceSelectorContext = createContext<VoiceSelectorContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
export const useVoiceSelector = () => {
|
||||
const context = useContext(VoiceSelectorContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"VoiceSelector components must be used within VoiceSelector"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type VoiceSelectorProps = ComponentProps<typeof Dialog> & {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onValueChange?: (value: string | undefined) => void;
|
||||
};
|
||||
|
||||
export const VoiceSelector = ({
|
||||
value: valueProp,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
open: openProp,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
...props
|
||||
}: VoiceSelectorProps) => {
|
||||
const [value, setValue] = useControllableState({
|
||||
defaultProp: defaultValue,
|
||||
onChange: onValueChange,
|
||||
prop: valueProp,
|
||||
});
|
||||
|
||||
const [open, setOpen] = useControllableState({
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
prop: openProp,
|
||||
});
|
||||
|
||||
const voiceSelectorContext = useMemo(
|
||||
() => ({ open, setOpen, setValue, value }),
|
||||
[value, setValue, open, setOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<VoiceSelectorContext.Provider value={voiceSelectorContext}>
|
||||
<Dialog onOpenChange={setOpen} open={open} {...props}>
|
||||
{children}
|
||||
</Dialog>
|
||||
</VoiceSelectorContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type VoiceSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
|
||||
|
||||
export const VoiceSelectorTrigger = (props: VoiceSelectorTriggerProps) => (
|
||||
<DialogTrigger {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
||||
title?: ReactNode;
|
||||
};
|
||||
|
||||
export const VoiceSelectorContent = ({
|
||||
className,
|
||||
children,
|
||||
title = "Voice Selector",
|
||||
...props
|
||||
}: VoiceSelectorContentProps) => (
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
className={cn("p-0", className)}
|
||||
{...props}
|
||||
>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
);
|
||||
|
||||
export type VoiceSelectorDialogProps = ComponentProps<typeof CommandDialog>;
|
||||
|
||||
export const VoiceSelectorDialog = (props: VoiceSelectorDialogProps) => (
|
||||
<CommandDialog {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorInputProps = ComponentProps<typeof CommandInput>;
|
||||
|
||||
export const VoiceSelectorInput = ({
|
||||
className,
|
||||
...props
|
||||
}: VoiceSelectorInputProps) => (
|
||||
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorListProps = ComponentProps<typeof CommandList>;
|
||||
|
||||
export const VoiceSelectorList = (props: VoiceSelectorListProps) => (
|
||||
<CommandList {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
|
||||
|
||||
export const VoiceSelectorEmpty = (props: VoiceSelectorEmptyProps) => (
|
||||
<CommandEmpty {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorGroupProps = ComponentProps<typeof CommandGroup>;
|
||||
|
||||
export const VoiceSelectorGroup = (props: VoiceSelectorGroupProps) => (
|
||||
<CommandGroup {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorItemProps = ComponentProps<typeof CommandItem>;
|
||||
|
||||
export const VoiceSelectorItem = ({
|
||||
className,
|
||||
...props
|
||||
}: VoiceSelectorItemProps) => (
|
||||
<CommandItem className={cn("px-4 py-2", className)} {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
|
||||
|
||||
export const VoiceSelectorShortcut = (props: VoiceSelectorShortcutProps) => (
|
||||
<CommandShortcut {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorSeparatorProps = ComponentProps<
|
||||
typeof CommandSeparator
|
||||
>;
|
||||
|
||||
export const VoiceSelectorSeparator = (props: VoiceSelectorSeparatorProps) => (
|
||||
<CommandSeparator {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorGenderProps = ComponentProps<"span"> & {
|
||||
value?:
|
||||
| "male"
|
||||
| "female"
|
||||
| "transgender"
|
||||
| "androgyne"
|
||||
| "non-binary"
|
||||
| "intersex";
|
||||
};
|
||||
|
||||
export const VoiceSelectorGender = ({
|
||||
className,
|
||||
value,
|
||||
children,
|
||||
...props
|
||||
}: VoiceSelectorGenderProps) => {
|
||||
let icon: ReactNode | null = null;
|
||||
|
||||
switch (value) {
|
||||
case "male": {
|
||||
icon = <MarsIcon className="size-4" />;
|
||||
break;
|
||||
}
|
||||
case "female": {
|
||||
icon = <VenusIcon className="size-4" />;
|
||||
break;
|
||||
}
|
||||
case "transgender": {
|
||||
icon = <TransgenderIcon className="size-4" />;
|
||||
break;
|
||||
}
|
||||
case "androgyne": {
|
||||
icon = <MarsStrokeIcon className="size-4" />;
|
||||
break;
|
||||
}
|
||||
case "non-binary": {
|
||||
icon = <NonBinaryIcon className="size-4" />;
|
||||
break;
|
||||
}
|
||||
case "intersex": {
|
||||
icon = <VenusAndMarsIcon className="size-4" />;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
icon = <CircleSmallIcon className="size-4" />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn("text-muted-foreground text-xs", className)} {...props}>
|
||||
{children ?? icon}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type VoiceSelectorAccentProps = ComponentProps<"span"> & {
|
||||
value?:
|
||||
| "american"
|
||||
| "british"
|
||||
| "australian"
|
||||
| "canadian"
|
||||
| "irish"
|
||||
| "scottish"
|
||||
| "indian"
|
||||
| "south-african"
|
||||
| "new-zealand"
|
||||
| "spanish"
|
||||
| "french"
|
||||
| "german"
|
||||
| "italian"
|
||||
| "portuguese"
|
||||
| "brazilian"
|
||||
| "mexican"
|
||||
| "argentinian"
|
||||
| "japanese"
|
||||
| "chinese"
|
||||
| "korean"
|
||||
| "russian"
|
||||
| "arabic"
|
||||
| "dutch"
|
||||
| "swedish"
|
||||
| "norwegian"
|
||||
| "danish"
|
||||
| "finnish"
|
||||
| "polish"
|
||||
| "turkish"
|
||||
| "greek"
|
||||
| string;
|
||||
};
|
||||
|
||||
export const VoiceSelectorAccent = ({
|
||||
className,
|
||||
value,
|
||||
children,
|
||||
...props
|
||||
}: VoiceSelectorAccentProps) => {
|
||||
let emoji: string | null = null;
|
||||
|
||||
switch (value) {
|
||||
case "american": {
|
||||
emoji = "🇺🇸";
|
||||
break;
|
||||
}
|
||||
case "british": {
|
||||
emoji = "🇬🇧";
|
||||
break;
|
||||
}
|
||||
case "australian": {
|
||||
emoji = "🇦🇺";
|
||||
break;
|
||||
}
|
||||
case "canadian": {
|
||||
emoji = "🇨🇦";
|
||||
break;
|
||||
}
|
||||
case "irish": {
|
||||
emoji = "🇮🇪";
|
||||
break;
|
||||
}
|
||||
case "scottish": {
|
||||
emoji = "🏴";
|
||||
break;
|
||||
}
|
||||
case "indian": {
|
||||
emoji = "🇮🇳";
|
||||
break;
|
||||
}
|
||||
case "south-african": {
|
||||
emoji = "🇿🇦";
|
||||
break;
|
||||
}
|
||||
case "new-zealand": {
|
||||
emoji = "🇳🇿";
|
||||
break;
|
||||
}
|
||||
case "spanish": {
|
||||
emoji = "🇪🇸";
|
||||
break;
|
||||
}
|
||||
case "french": {
|
||||
emoji = "🇫🇷";
|
||||
break;
|
||||
}
|
||||
case "german": {
|
||||
emoji = "🇩🇪";
|
||||
break;
|
||||
}
|
||||
case "italian": {
|
||||
emoji = "🇮🇹";
|
||||
break;
|
||||
}
|
||||
case "portuguese": {
|
||||
emoji = "🇵🇹";
|
||||
break;
|
||||
}
|
||||
case "brazilian": {
|
||||
emoji = "🇧🇷";
|
||||
break;
|
||||
}
|
||||
case "mexican": {
|
||||
emoji = "🇲🇽";
|
||||
break;
|
||||
}
|
||||
case "argentinian": {
|
||||
emoji = "🇦🇷";
|
||||
break;
|
||||
}
|
||||
case "japanese": {
|
||||
emoji = "🇯🇵";
|
||||
break;
|
||||
}
|
||||
case "chinese": {
|
||||
emoji = "🇨🇳";
|
||||
break;
|
||||
}
|
||||
case "korean": {
|
||||
emoji = "🇰🇷";
|
||||
break;
|
||||
}
|
||||
case "russian": {
|
||||
emoji = "🇷🇺";
|
||||
break;
|
||||
}
|
||||
case "arabic": {
|
||||
emoji = "🇸🇦";
|
||||
break;
|
||||
}
|
||||
case "dutch": {
|
||||
emoji = "🇳🇱";
|
||||
break;
|
||||
}
|
||||
case "swedish": {
|
||||
emoji = "🇸🇪";
|
||||
break;
|
||||
}
|
||||
case "norwegian": {
|
||||
emoji = "🇳🇴";
|
||||
break;
|
||||
}
|
||||
case "danish": {
|
||||
emoji = "🇩🇰";
|
||||
break;
|
||||
}
|
||||
case "finnish": {
|
||||
emoji = "🇫🇮";
|
||||
break;
|
||||
}
|
||||
case "polish": {
|
||||
emoji = "🇵🇱";
|
||||
break;
|
||||
}
|
||||
case "turkish": {
|
||||
emoji = "🇹🇷";
|
||||
break;
|
||||
}
|
||||
case "greek": {
|
||||
emoji = "🇬🇷";
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
emoji = null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn("text-muted-foreground text-xs", className)} {...props}>
|
||||
{children ?? emoji}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type VoiceSelectorAgeProps = ComponentProps<"span">;
|
||||
|
||||
export const VoiceSelectorAge = ({
|
||||
className,
|
||||
...props
|
||||
}: VoiceSelectorAgeProps) => (
|
||||
<span
|
||||
className={cn("text-muted-foreground text-xs tabular-nums", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type VoiceSelectorNameProps = ComponentProps<"span">;
|
||||
|
||||
export const VoiceSelectorName = ({
|
||||
className,
|
||||
...props
|
||||
}: VoiceSelectorNameProps) => (
|
||||
<span
|
||||
className={cn("flex-1 truncate text-left font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type VoiceSelectorDescriptionProps = ComponentProps<"span">;
|
||||
|
||||
export const VoiceSelectorDescription = ({
|
||||
className,
|
||||
...props
|
||||
}: VoiceSelectorDescriptionProps) => (
|
||||
<span className={cn("text-muted-foreground text-xs", className)} {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorAttributesProps = ComponentProps<"div">;
|
||||
|
||||
export const VoiceSelectorAttributes = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: VoiceSelectorAttributesProps) => (
|
||||
<div className={cn("flex items-center text-xs", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type VoiceSelectorBulletProps = ComponentProps<"span">;
|
||||
|
||||
export const VoiceSelectorBullet = ({
|
||||
className,
|
||||
...props
|
||||
}: VoiceSelectorBulletProps) => (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("select-none text-border", className)}
|
||||
{...props}
|
||||
>
|
||||
•
|
||||
</span>
|
||||
);
|
||||
|
||||
export type VoiceSelectorPreviewProps = Omit<
|
||||
ComponentProps<"button">,
|
||||
"children"
|
||||
> & {
|
||||
playing?: boolean;
|
||||
loading?: boolean;
|
||||
onPlay?: () => void;
|
||||
};
|
||||
|
||||
export const VoiceSelectorPreview = ({
|
||||
className,
|
||||
playing,
|
||||
loading,
|
||||
onPlay,
|
||||
onClick,
|
||||
...props
|
||||
}: VoiceSelectorPreviewProps) => {
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
onClick?.(event);
|
||||
onPlay?.();
|
||||
},
|
||||
[onClick, onPlay]
|
||||
);
|
||||
|
||||
let icon = <PlayIcon className="size-3" />;
|
||||
|
||||
if (loading) {
|
||||
icon = <Spinner className="size-3" />;
|
||||
} else if (playing) {
|
||||
icon = <PauseIcon className="size-3" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={playing ? "Pause preview" : "Play preview"}
|
||||
className={cn("size-6", className)}
|
||||
disabled={loading}
|
||||
onClick={handleClick}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,263 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export interface WebPreviewContextValue {
|
||||
url: string;
|
||||
setUrl: (url: string) => void;
|
||||
consoleOpen: boolean;
|
||||
setConsoleOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const WebPreviewContext = createContext<WebPreviewContextValue | null>(null);
|
||||
|
||||
const useWebPreview = () => {
|
||||
const context = useContext(WebPreviewContext);
|
||||
if (!context) {
|
||||
throw new Error("WebPreview components must be used within a WebPreview");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type WebPreviewProps = ComponentProps<"div"> & {
|
||||
defaultUrl?: string;
|
||||
onUrlChange?: (url: string) => void;
|
||||
};
|
||||
|
||||
export const WebPreview = ({
|
||||
className,
|
||||
children,
|
||||
defaultUrl = "",
|
||||
onUrlChange,
|
||||
...props
|
||||
}: WebPreviewProps) => {
|
||||
const [url, setUrl] = useState(defaultUrl);
|
||||
const [consoleOpen, setConsoleOpen] = useState(false);
|
||||
|
||||
const handleUrlChange = useCallback(
|
||||
(newUrl: string) => {
|
||||
setUrl(newUrl);
|
||||
onUrlChange?.(newUrl);
|
||||
},
|
||||
[onUrlChange]
|
||||
);
|
||||
|
||||
const contextValue = useMemo<WebPreviewContextValue>(
|
||||
() => ({
|
||||
consoleOpen,
|
||||
setConsoleOpen,
|
||||
setUrl: handleUrlChange,
|
||||
url,
|
||||
}),
|
||||
[consoleOpen, handleUrlChange, url]
|
||||
);
|
||||
|
||||
return (
|
||||
<WebPreviewContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full flex-col rounded-lg border bg-card",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</WebPreviewContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type WebPreviewNavigationProps = ComponentProps<"div">;
|
||||
|
||||
export const WebPreviewNavigation = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewNavigationProps) => (
|
||||
<div
|
||||
className={cn("flex items-center gap-1 border-b p-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
export const WebPreviewNavigationButton = ({
|
||||
onClick,
|
||||
disabled,
|
||||
tooltip,
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewNavigationButtonProps) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Button className="h-8 w-8 p-0 hover:text-foreground" disabled={disabled} onClick={onClick} size="sm" variant="ghost" {...props} />}>{children}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
export type WebPreviewUrlProps = ComponentProps<typeof Input>;
|
||||
|
||||
export const WebPreviewUrl = ({
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
...props
|
||||
}: WebPreviewUrlProps) => {
|
||||
const { url, setUrl } = useWebPreview();
|
||||
const [prevUrl, setPrevUrl] = useState(url);
|
||||
const [inputValue, setInputValue] = useState(url);
|
||||
|
||||
// Sync input value with context URL when it changes externally (derived state pattern)
|
||||
if (url !== prevUrl) {
|
||||
setPrevUrl(url);
|
||||
setInputValue(url);
|
||||
}
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value);
|
||||
onChange?.(event);
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
const target = event.target as HTMLInputElement;
|
||||
setUrl(target.value);
|
||||
}
|
||||
onKeyDown?.(event);
|
||||
},
|
||||
[setUrl, onKeyDown]
|
||||
);
|
||||
|
||||
return (
|
||||
<Input
|
||||
className="h-8 flex-1 text-sm"
|
||||
onChange={onChange ?? handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter URL..."
|
||||
value={value ?? inputValue}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type WebPreviewBodyProps = ComponentProps<"iframe"> & {
|
||||
loading?: ReactNode;
|
||||
};
|
||||
|
||||
export const WebPreviewBody = ({
|
||||
className,
|
||||
loading,
|
||||
src,
|
||||
...props
|
||||
}: WebPreviewBodyProps) => {
|
||||
const { url } = useWebPreview();
|
||||
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<iframe
|
||||
className={cn("size-full", className)}
|
||||
// oxlint-disable-next-line eslint-plugin-react(iframe-missing-sandbox)
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation"
|
||||
src={(src ?? url) || undefined}
|
||||
title="Preview"
|
||||
{...props}
|
||||
/>
|
||||
{loading}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type WebPreviewConsoleProps = ComponentProps<"div"> & {
|
||||
logs?: {
|
||||
level: "log" | "warn" | "error";
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const WebPreviewConsole = ({
|
||||
className,
|
||||
logs = [],
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewConsoleProps) => {
|
||||
const { consoleOpen, setConsoleOpen } = useWebPreview();
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
className={cn("border-t bg-muted/50 font-mono text-sm", className)}
|
||||
onOpenChange={setConsoleOpen}
|
||||
open={consoleOpen}
|
||||
{...props}
|
||||
>
|
||||
<CollapsibleTrigger render={<Button className="flex w-full items-center justify-between p-4 text-left font-medium hover:bg-muted/50" variant="ghost" />}>Console
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
consoleOpen && "rotate-180"
|
||||
)}
|
||||
/></CollapsibleTrigger>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"px-4 pb-4",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in"
|
||||
)}
|
||||
>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-muted-foreground">No console output</p>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs",
|
||||
log.level === "error" && "text-destructive",
|
||||
log.level === "warn" && "text-yellow-600",
|
||||
log.level === "log" && "text-foreground"
|
||||
)}
|
||||
key={`${log.timestamp.getTime()}-${log.level}-${log.message}`}
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{log.timestamp.toLocaleTimeString()}
|
||||
</span>{" "}
|
||||
{log.message}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
ArrowUp,
|
||||
ChevronDown,
|
||||
FolderGit2,
|
||||
GitBranch,
|
||||
Hand,
|
||||
Laptop,
|
||||
Mic,
|
||||
Plus,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
import { type KeyboardEvent, useCallback, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ChatInputProps = {
|
||||
onSubmit: (text: string) => void;
|
||||
status: ChatStatus;
|
||||
onStop?: () => void;
|
||||
};
|
||||
|
||||
const iconButton =
|
||||
"flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground";
|
||||
const pillButton =
|
||||
"flex h-8 items-center gap-1.5 rounded-lg px-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground";
|
||||
const contextPill =
|
||||
"flex h-7 items-center gap-1.5 rounded-md px-2 text-[13px] text-muted-foreground transition-colors hover:bg-foreground/5 hover:text-foreground";
|
||||
|
||||
export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
|
||||
const [value, setValue] = useState("");
|
||||
const isGenerating = status === "submitted" || status === "streaming";
|
||||
const canSend = value.trim().length > 0 && !isGenerating;
|
||||
|
||||
const submit = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || isGenerating) {
|
||||
return;
|
||||
}
|
||||
onSubmit(trimmed);
|
||||
setValue("");
|
||||
}, [value, isGenerating, onSubmit]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
!event.shiftKey &&
|
||||
!event.nativeEvent.isComposing
|
||||
) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
},
|
||||
[submit]
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
className="w-full overflow-hidden rounded-[28px] border border-border/60 bg-[oklch(0.172_0.006_277)] shadow-sm"
|
||||
>
|
||||
{/* Top (lighter) card: textarea + toolbar, with a slightly smaller bottom radius */}
|
||||
<div className="rounded-b-[22px] bg-card">
|
||||
<textarea
|
||||
aria-label="Message"
|
||||
className="field-sizing-content block max-h-48 min-h-16 w-full resize-none bg-transparent px-5 pt-5 pb-2 text-base text-foreground outline-none placeholder:text-muted-foreground"
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Do anything"
|
||||
rows={1}
|
||||
value={value}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 px-3 pb-3">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<button aria-label="Add context" className={iconButton} type="button">
|
||||
<Plus className="size-[18px]" />
|
||||
</button>
|
||||
<button className={pillButton} type="button">
|
||||
<Hand className="size-4" />
|
||||
<span className="truncate">Default permissions</span>
|
||||
<ChevronDown className="size-4 opacity-70" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button className={cn(pillButton, "mr-1")} type="button">
|
||||
<span className="font-medium text-foreground">Custom</span>
|
||||
<span>High</span>
|
||||
<ChevronDown className="size-4 opacity-70" />
|
||||
</button>
|
||||
<button aria-label="Voice input" className={iconButton} type="button">
|
||||
<Mic className="size-[18px]" />
|
||||
</button>
|
||||
<button
|
||||
aria-label={isGenerating ? "Stop" : "Send"}
|
||||
className={cn(
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-full transition-colors",
|
||||
canSend || isGenerating
|
||||
? "bg-foreground text-background hover:bg-foreground/90"
|
||||
: "bg-muted-foreground/30 text-foreground/70"
|
||||
)}
|
||||
disabled={!(canSend || isGenerating)}
|
||||
onClick={isGenerating && onStop ? onStop : undefined}
|
||||
type={isGenerating && onStop ? "button" : "submit"}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Square className="size-3.5" />
|
||||
) : (
|
||||
<ArrowUp className="size-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom (darker) card peeking out below, more rounded: context selectors */}
|
||||
<div className="flex flex-wrap items-center gap-1 px-3 pt-2.5 pb-3">
|
||||
<button className={contextPill} type="button">
|
||||
<FolderGit2 className="size-4" />
|
||||
<span>design-ai</span>
|
||||
<ChevronDown className="size-3.5 opacity-70" />
|
||||
</button>
|
||||
<button className={contextPill} type="button">
|
||||
<Laptop className="size-4" />
|
||||
<span>Work locally</span>
|
||||
<ChevronDown className="size-3.5 opacity-70" />
|
||||
</button>
|
||||
<button className={contextPill} type="button">
|
||||
<GitBranch className="size-4" />
|
||||
<span>main</span>
|
||||
<ChevronDown className="size-3.5 opacity-70" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { nanoid } from "nanoid";
|
||||
import type { ChatStatus } from "ai";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import {
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageResponse,
|
||||
} from "@/components/ai-elements/message";
|
||||
import { ChatInput } from "@/components/chat/chat-input";
|
||||
|
||||
type ChatMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
};
|
||||
|
||||
const HEADING = "What should we build in design-ai?";
|
||||
|
||||
export function ChatPanel() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [status, setStatus] = useState<ChatStatus>("ready");
|
||||
|
||||
// UI-only: append the user's message and a mock assistant reply.
|
||||
const send = useCallback((text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: nanoid(), role: "user", text: trimmed },
|
||||
]);
|
||||
setStatus("submitted");
|
||||
|
||||
window.setTimeout(() => {
|
||||
setStatus("streaming");
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: nanoid(),
|
||||
role: "assistant",
|
||||
text: `This is a **UI-only preview** of temetro. Connecting to your records and a model comes next.\n\nYou asked:\n\n> ${trimmed}`,
|
||||
},
|
||||
]);
|
||||
window.setTimeout(() => setStatus("ready"), 250);
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
const handleStop = useCallback(() => setStatus("ready"), []);
|
||||
|
||||
const promptInput = (
|
||||
<ChatInput onStop={handleStop} onSubmit={send} status={status} />
|
||||
);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-4">
|
||||
<div className="flex w-full max-w-3xl flex-col items-center gap-10">
|
||||
<h1 className="text-center text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
|
||||
{HEADING}
|
||||
</h1>
|
||||
{promptInput}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<Conversation>
|
||||
<ConversationContent className="mx-auto w-full max-w-3xl">
|
||||
{messages.map((message) => (
|
||||
<Message from={message.role} key={message.id}>
|
||||
<MessageContent>
|
||||
{message.role === "assistant" ? (
|
||||
<MessageResponse>{message.text}</MessageResponse>
|
||||
) : (
|
||||
<span className="whitespace-pre-wrap">{message.text}</span>
|
||||
)}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
))}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
<div className="mx-auto w-full max-w-3xl px-4 pb-4">{promptInput}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsSection,
|
||||
whiteButton,
|
||||
} from "@/components/settings/settings-parts";
|
||||
|
||||
export function BillingPanel() {
|
||||
return (
|
||||
<>
|
||||
<SettingsCard className="flex flex-col gap-6 p-6 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-xl font-semibold tracking-tight">Early Member</h3>
|
||||
<Badge className="bg-emerald-500/15 text-emerald-400">Active</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
For our founding community of early members.
|
||||
</p>
|
||||
<Button className={cn("rounded-lg", whiteButton)}>Change plan</Button>
|
||||
</div>
|
||||
<div className="sm:text-right">
|
||||
<p className="text-3xl font-semibold tracking-tight">Free</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
4.00% + $0.40 per transaction
|
||||
</p>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsSection
|
||||
action={
|
||||
<Button className="rounded-lg" variant="secondary">
|
||||
Add payment method
|
||||
</Button>
|
||||
}
|
||||
description="Cards used to pay for your temetro subscription"
|
||||
title="Payment methods"
|
||||
>
|
||||
<SettingsCard className="flex items-center justify-center p-12">
|
||||
<p className="text-sm text-muted-foreground">No payment methods on file</p>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
action={
|
||||
<Button className="rounded-lg" variant="secondary">
|
||||
Edit
|
||||
</Button>
|
||||
}
|
||||
description="Used on invoices for your temetro subscription"
|
||||
title="Billing address"
|
||||
>
|
||||
<SettingsCard className="p-5">
|
||||
<p className="text-sm">khalid</p>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Copy } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
export function SettingsSection({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold tracking-tight">{title}</h2>
|
||||
{description ? (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsCard({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("rounded-2xl border border-border bg-card/30", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToggleRow({
|
||||
title,
|
||||
description,
|
||||
defaultChecked = false,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
defaultChecked?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<SettingsCard className="flex items-center justify-between gap-4 px-4 py-3.5">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
{description ? (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch defaultChecked={defaultChecked} />
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyField({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
{description ? (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex h-9 w-full items-center gap-2 rounded-3xl bg-input/50 pr-1 pl-3 sm:w-80">
|
||||
<span className="flex-1 truncate text-sm text-muted-foreground">
|
||||
{value}
|
||||
</span>
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-accent"
|
||||
type="button"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Light/white CTA used for primary actions on the dark settings surface. */
|
||||
export const whiteButton =
|
||||
"bg-foreground text-background hover:bg-foreground/90";
|
||||
|
||||
/** Field label with an optional required asterisk. */
|
||||
export function FieldLabel({
|
||||
children,
|
||||
required,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="text-sm font-medium">
|
||||
{children}
|
||||
{required ? <span className="text-muted-foreground"> *</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, Plus } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
CopyField,
|
||||
FieldLabel,
|
||||
SettingsCard,
|
||||
SettingsSection,
|
||||
ToggleRow,
|
||||
whiteButton,
|
||||
} from "@/components/settings/settings-parts";
|
||||
|
||||
const customerNotifications = [
|
||||
{
|
||||
title: "Order confirmation",
|
||||
description: "Sent when a customer completes a one-time purchase",
|
||||
},
|
||||
{
|
||||
title: "Subscription confirmation",
|
||||
description: "Sent when a customer starts a new subscription",
|
||||
},
|
||||
{
|
||||
title: "Subscription cycled",
|
||||
description: "Sent when a subscription automatically renews",
|
||||
},
|
||||
{
|
||||
title: "Trial converted",
|
||||
description: "Sent when a trial ends and the subscription becomes paid",
|
||||
},
|
||||
{
|
||||
title: "Renewal reminder",
|
||||
description: "Sent 7 days before a subscription with a long billing cycle renews",
|
||||
},
|
||||
{
|
||||
title: "Trial conversion reminder",
|
||||
description: "Sent before a trial ends and converts to a paid subscription",
|
||||
},
|
||||
{
|
||||
title: "Subscription updated",
|
||||
description: "Sent when a customer changes their subscription to a different product",
|
||||
},
|
||||
];
|
||||
|
||||
export function PreferencesPanel() {
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title="Organization">
|
||||
<SettingsCard className="space-y-6 p-5">
|
||||
<CopyField
|
||||
description="Unique identifier for your organization"
|
||||
label="Identifier"
|
||||
value="62a5278f-91c6-4912-b711-ee1c9c2f0a73"
|
||||
/>
|
||||
<CopyField
|
||||
description="Used for Customer Portal, Transaction Statements, etc."
|
||||
label="Organization Slug"
|
||||
value="khalid"
|
||||
/>
|
||||
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>Logo</FieldLabel>
|
||||
<Avatar className="size-10 rounded-xl" size="lg">
|
||||
<AvatarFallback className="rounded-xl bg-muted text-sm font-medium">
|
||||
K
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<FieldLabel required>Organization Name</FieldLabel>
|
||||
<Input defaultValue="khalid" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>Country</FieldLabel>
|
||||
<button
|
||||
className="flex h-9 w-full items-center justify-between rounded-3xl bg-input/50 px-3 text-sm text-muted-foreground transition-colors hover:bg-input/70"
|
||||
type="button"
|
||||
>
|
||||
Select country
|
||||
<ChevronDown className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel required>Website</FieldLabel>
|
||||
<Input placeholder="https://acme.com" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel required>Support Email</FieldLabel>
|
||||
<Input placeholder="support@acme.com" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<div className="space-y-0.5">
|
||||
<FieldLabel>Social Media</FieldLabel>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your personal social media links are used for identity verification. They
|
||||
will never be shown publicly.
|
||||
</p>
|
||||
</div>
|
||||
<Button className="rounded-lg" size="sm" variant="outline">
|
||||
<Plus className="size-4" />
|
||||
Add Social
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
description="Emails automatically sent to customers for purchases, renewals, and other subscription lifecycle events"
|
||||
title="Customer notifications"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{customerNotifications.map((item) => (
|
||||
<ToggleRow
|
||||
defaultChecked
|
||||
description={item.description}
|
||||
key={item.title}
|
||||
title={item.title}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
description="Emails sent to members of your organization for account and product activity"
|
||||
title="Account notifications"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ToggleRow
|
||||
defaultChecked
|
||||
description="Send a notification when new orders are created"
|
||||
title="New Orders"
|
||||
/>
|
||||
<ToggleRow
|
||||
defaultChecked
|
||||
description="Send a notification when new subscriptions are created"
|
||||
title="New Subscriptions"
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
description="Manage alpha & beta features for your organization"
|
||||
title="Features"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ToggleRow
|
||||
description="Show translated checkouts to your customers"
|
||||
title="Localized Checkout"
|
||||
/>
|
||||
<ToggleRow
|
||||
description="Enable seat-based pricing for subscription products. Requires the member model to be enabled."
|
||||
title="Seat-Based Billing"
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
description="Manage access tokens to authenticate with the temetro API"
|
||||
title="Developers"
|
||||
>
|
||||
<SettingsCard className="flex flex-col items-start gap-4 p-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You don't have any active organization access tokens.
|
||||
</p>
|
||||
<Button className={cn("rounded-lg", whiteButton)} size="sm">
|
||||
Create token
|
||||
</Button>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
description="Irreversible actions for this organization"
|
||||
title="Danger Zone"
|
||||
>
|
||||
<SettingsCard className="flex items-center justify-between gap-4 p-4">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">Delete Organization</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Permanently delete this organization and all associated data. This action
|
||||
cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<Button className="rounded-lg bg-destructive text-white hover:bg-destructive/90">
|
||||
Delete
|
||||
</Button>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsSection,
|
||||
} from "@/components/settings/settings-parts";
|
||||
import { BillingPanel } from "@/components/settings/settings-billing";
|
||||
import { PreferencesPanel } from "@/components/settings/settings-preferences";
|
||||
|
||||
const TABS = [
|
||||
"Preferences",
|
||||
"Billing",
|
||||
"Members",
|
||||
"Webhooks",
|
||||
"Custom Fields",
|
||||
] as const;
|
||||
|
||||
type Tab = (typeof TABS)[number];
|
||||
|
||||
function PlaceholderPanel({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<SettingsSection description={description} title={title}>
|
||||
<SettingsCard className="flex items-center justify-center p-12">
|
||||
<p className="text-sm text-muted-foreground">Nothing here yet.</p>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsView() {
|
||||
const [tab, setTab] = useState<Tab>("Preferences");
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl px-6 py-10">
|
||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{tab}</h1>
|
||||
<nav className="flex flex-wrap items-center gap-1">
|
||||
{TABS.map((item) => (
|
||||
<button
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-1.5 text-sm transition-colors",
|
||||
tab === item
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
key={item}
|
||||
onClick={() => setTab(item)}
|
||||
type="button"
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 space-y-12">
|
||||
{tab === "Preferences" && <PreferencesPanel />}
|
||||
{tab === "Billing" && <BillingPanel />}
|
||||
{tab === "Members" && (
|
||||
<PlaceholderPanel
|
||||
description="Invite and manage members of your organization"
|
||||
title="Members"
|
||||
/>
|
||||
)}
|
||||
{tab === "Webhooks" && (
|
||||
<PlaceholderPanel
|
||||
description="Send event notifications to your own endpoints"
|
||||
title="Webhooks"
|
||||
/>
|
||||
)}
|
||||
{tab === "Custom Fields" && (
|
||||
<PlaceholderPanel
|
||||
description="Collect additional information from your customers"
|
||||
title="Custom Fields"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Bookmark,
|
||||
MessageSquare,
|
||||
Plus,
|
||||
Settings,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { Logo } from "@/components/sidebar-02/logo";
|
||||
import type { Route } from "./nav-main";
|
||||
import DashboardNavigation from "@/components/sidebar-02/nav-main";
|
||||
import { NotificationsPopover } from "@/components/sidebar-02/nav-notifications";
|
||||
import { TeamSwitcher } from "@/components/sidebar-02/team-switcher";
|
||||
|
||||
const sampleNotifications = [
|
||||
{
|
||||
id: "1",
|
||||
avatar: "/avatars/01.png",
|
||||
fallback: "LR",
|
||||
text: "New lab results are available.",
|
||||
time: "10m ago",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
avatar: "/avatars/02.png",
|
||||
fallback: "PC",
|
||||
text: "A patient chart was updated.",
|
||||
time: "1h ago",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
avatar: "/avatars/03.png",
|
||||
fallback: "CT",
|
||||
text: "New message from the care team.",
|
||||
time: "2h ago",
|
||||
},
|
||||
];
|
||||
|
||||
const dashboardRoutes: Route[] = [
|
||||
{
|
||||
id: "new-chat",
|
||||
title: "New chat",
|
||||
icon: <Plus className="size-4" />,
|
||||
link: "/",
|
||||
},
|
||||
{
|
||||
id: "patients",
|
||||
title: "Patients",
|
||||
icon: <Users className="size-4" />,
|
||||
link: "#",
|
||||
},
|
||||
{
|
||||
id: "conversations",
|
||||
title: "Conversations",
|
||||
icon: <MessageSquare className="size-4" />,
|
||||
link: "#",
|
||||
},
|
||||
{
|
||||
id: "saved",
|
||||
title: "Saved",
|
||||
icon: <Bookmark className="size-4" />,
|
||||
link: "#",
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
title: "Settings",
|
||||
icon: <Settings className="size-4" />,
|
||||
link: "/settings",
|
||||
},
|
||||
];
|
||||
|
||||
const teams = [{ id: "1", name: "temetro", logo: Logo, plan: "Clinic" }];
|
||||
|
||||
export function DashboardSidebar() {
|
||||
const { state } = useSidebar();
|
||||
const isCollapsed = state === "collapsed";
|
||||
|
||||
return (
|
||||
<Sidebar variant="inset" collapsible="icon">
|
||||
<SidebarHeader
|
||||
className={cn(
|
||||
"flex md:pt-3.5",
|
||||
isCollapsed
|
||||
? "flex-row items-center justify-between gap-y-4 md:flex-col md:items-start md:justify-start"
|
||||
: "flex-row items-center justify-between"
|
||||
)}
|
||||
>
|
||||
<a href="#" className="flex items-center gap-2">
|
||||
<Image
|
||||
src="/temetro-logo.png"
|
||||
alt="temetro"
|
||||
width={32}
|
||||
height={32}
|
||||
className="size-8 shrink-0"
|
||||
priority
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<span className="font-semibold text-black dark:text-white">
|
||||
temetro
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
|
||||
<motion.div
|
||||
key={isCollapsed ? "header-collapsed" : "header-expanded"}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
isCollapsed ? "flex-row md:flex-col-reverse" : "flex-row"
|
||||
)}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<NotificationsPopover notifications={sampleNotifications} />
|
||||
<SidebarTrigger />
|
||||
</motion.div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent className="gap-4 px-2 py-4">
|
||||
<DashboardNavigation routes={dashboardRoutes} />
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="px-2">
|
||||
<TeamSwitcher teams={teams} />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { DashboardSidebar } from "@/components/sidebar-02/app-sidebar";
|
||||
|
||||
export default function Sidebar02() {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="relative flex h-dvh w-full">
|
||||
<DashboardSidebar />
|
||||
<SidebarInset className="flex flex-col" />
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { SVGProps } from "react";
|
||||
|
||||
export const Logo = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="48"
|
||||
viewBox="0 0 40 48"
|
||||
width="40"
|
||||
{...props}
|
||||
>
|
||||
<clipPath id="a">
|
||||
<path d="m0 0h40v48h-40z" />
|
||||
</clipPath>
|
||||
<g clipPath="url(#a)">
|
||||
<path d="m25.0887 5.05386-3.933-1.05386-3.3145 12.3696-2.9923-11.16736-3.9331 1.05386 3.233 12.0655-8.05262-8.0526-2.87919 2.8792 8.83271 8.8328-10.99975-2.9474-1.05385625 3.933 12.01860625 3.2204c-.1376-.5935-.2104-1.2119-.2104-1.8473 0-4.4976 3.646-8.1436 8.1437-8.1436 4.4976 0 8.1436 3.646 8.1436 8.1436 0 .6313-.0719 1.2459-.2078 1.8359l10.9227 2.9267 1.0538-3.933-12.0664-3.2332 11.0005-2.9476-1.0539-3.933-12.0659 3.233 8.0526-8.0526-2.8792-2.87916-8.7102 8.71026z" />
|
||||
<path d="m27.8723 26.2214c-.3372 1.4256-1.0491 2.7063-2.0259 3.7324l7.913 7.9131 2.8792-2.8792z" />
|
||||
<path d="m25.7665 30.0366c-.9886 1.0097-2.2379 1.7632-3.6389 2.1515l2.8794 10.746 3.933-1.0539z" />
|
||||
<path d="m21.9807 32.2274c-.65.1671-1.3313.2559-2.0334.2559-.7522 0-1.4806-.102-2.1721-.2929l-2.882 10.7558 3.933 1.0538z" />
|
||||
<path d="m17.6361 32.1507c-1.3796-.4076-2.6067-1.1707-3.5751-2.1833l-7.9325 7.9325 2.87919 2.8792z" />
|
||||
<path d="m13.9956 29.8973c-.9518-1.019-1.6451-2.2826-1.9751-3.6862l-10.95836 2.9363 1.05385 3.933z" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuItem as SidebarMenuSubItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
export type Route = {
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
link: string;
|
||||
subs?: {
|
||||
title: string;
|
||||
link: string;
|
||||
icon?: React.ReactNode;
|
||||
}[];
|
||||
};
|
||||
|
||||
export default function DashboardNavigation({ routes }: { routes: Route[] }) {
|
||||
const { state } = useSidebar();
|
||||
const isCollapsed = state === "collapsed";
|
||||
const [openCollapsible, setOpenCollapsible] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
{routes.map((route) => {
|
||||
const isOpen = !isCollapsed && openCollapsible === route.id;
|
||||
const hasSubRoutes = !!route.subs?.length;
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={route.id}>
|
||||
{hasSubRoutes ? (
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={(open) =>
|
||||
setOpenCollapsible(open ? route.id : null)
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
<CollapsibleTrigger render={<SidebarMenuButton className={cn(
|
||||
"flex w-full items-center rounded-lg px-2 transition-colors",
|
||||
isOpen
|
||||
? "bg-sidebar-muted text-foreground"
|
||||
: "text-muted-foreground hover:bg-sidebar-muted hover:text-foreground",
|
||||
isCollapsed && "justify-center"
|
||||
)} />}>{route.icon}{!isCollapsed && (
|
||||
<span className="ml-2 flex-1 text-sm font-medium">
|
||||
{route.title}
|
||||
</span>
|
||||
)}{!isCollapsed && hasSubRoutes && (
|
||||
<span className="ml-auto">
|
||||
{isOpen ? (
|
||||
<ChevronUp className="size-4" />
|
||||
) : (
|
||||
<ChevronDown className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
)}</CollapsibleTrigger>
|
||||
|
||||
{!isCollapsed && (
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub className="my-1 ml-3.5 ">
|
||||
{route.subs?.map((subRoute) => (
|
||||
<SidebarMenuSubItem
|
||||
key={`${route.id}-${subRoute.title}`}
|
||||
className="h-auto"
|
||||
>
|
||||
<SidebarMenuSubButton render={<Link href={subRoute.link} prefetch={true} className="flex items-center rounded-md px-4 py-1.5 text-sm font-medium text-muted-foreground hover:bg-sidebar-muted hover:text-foreground" />}>{subRoute.title}</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
</Collapsible>
|
||||
) : (
|
||||
<SidebarMenuButton tooltip={route.title} render={<Link href={route.link} prefetch={true} className={cn(
|
||||
"flex items-center rounded-lg px-2 transition-colors text-muted-foreground hover:bg-sidebar-muted hover:text-foreground",
|
||||
isCollapsed && "justify-center"
|
||||
)} />}>{route.icon}{!isCollapsed && (
|
||||
<span className="ml-2 text-sm font-medium">
|
||||
{route.title}
|
||||
</span>
|
||||
)}</SidebarMenuButton>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { BellIcon } from "lucide-react";
|
||||
|
||||
type Notification = {
|
||||
id: string;
|
||||
avatar: string;
|
||||
fallback: string;
|
||||
text: string;
|
||||
time: string;
|
||||
};
|
||||
|
||||
export function NotificationsPopover({
|
||||
notifications,
|
||||
}: {
|
||||
notifications: Notification[];
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" className="rounded-full" aria-label="Open notifications" />}><BellIcon className="size-5" /></DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" className="w-80 my-6">
|
||||
<DropdownMenuLabel>Notifications</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{notifications.map(({ id, avatar, fallback, text, time }) => (
|
||||
<DropdownMenuItem key={id} className="flex items-start gap-3">
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage src={avatar} alt="Avatar" />
|
||||
<AvatarFallback>{fallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{text}</span>
|
||||
<span className="text-xs text-muted-foreground">{time}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="justify-center text-sm text-muted-foreground hover:text-primary">
|
||||
View all notifications
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { ChevronsUpDown, Plus } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
type Team = {
|
||||
name: string;
|
||||
logo: React.ElementType;
|
||||
plan: string;
|
||||
};
|
||||
|
||||
export function TeamSwitcher({ teams }: { teams: Team[] }) {
|
||||
const { isMobile } = useSidebar();
|
||||
const [activeTeam, setActiveTeam] = React.useState(teams[0]);
|
||||
|
||||
if (!activeTeam) return null;
|
||||
|
||||
const Logo = activeTeam.logo;
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<SidebarMenuButton size="lg" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" />}><div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-background text-foreground">
|
||||
<Logo className="size-4" />
|
||||
</div><div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">
|
||||
{activeTeam.name}
|
||||
</span>
|
||||
<span className="truncate text-xs">{activeTeam.plan}</span>
|
||||
</div><ChevronsUpDown className="ml-auto" /></DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg mb-4"
|
||||
align="start"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
Teams
|
||||
</DropdownMenuLabel>
|
||||
{teams.map((team, index) => (
|
||||
<DropdownMenuItem
|
||||
key={team.name}
|
||||
onClick={() => setActiveTeam(team)}
|
||||
className="gap-2 p-2"
|
||||
>
|
||||
<div className="flex size-6 items-center justify-center rounded-sm border">
|
||||
<team.logo className="size-4 shrink-0" />
|
||||
</div>
|
||||
{team.name}
|
||||
<DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="gap-2 p-2">
|
||||
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
|
||||
<Plus className="size-4" />
|
||||
</div>
|
||||
<div className="font-medium text-muted-foreground">Add team</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Root
|
||||
data-slot="accordion"
|
||||
className={cn(
|
||||
"flex w-full flex-col overflow-hidden rounded-2xl border",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("not-last:border-b data-open:bg-muted/50", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AccordionPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"group/accordion-trigger relative flex flex-1 items-start justify-between gap-6 border border-transparent p-4 text-left text-sm font-medium transition-all outline-none hover:underline aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
|
||||
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AccordionPrimitive.Panel.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Panel
|
||||
data-slot="accordion-content"
|
||||
className="overflow-hidden px-4 text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-(--accordion-panel-height) pt-0 pb-4 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Panel>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"group/alert relative grid w-full gap-0.5 rounded-2xl border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
className={cn("absolute top-2.5 right-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AvatarPrimitive.Root.Props & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.Fallback.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-3xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,87 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[>[data-variant=outline]]:*:data-[slot=input-group]:border-border has-[>[data-variant=outline]]:*:data-[slot=select-trigger]:border-border has-[>[data-variant=outline]]:[&>[data-slot=input-group]:has(:focus-visible)]:border-ring has-[>[data-variant=outline]]:[&>[data-slot=select-trigger]:focus-visible]:border-ring has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-4xl [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[>[data-variant=outline]]:[&>input]:border-border has-[>[data-variant=outline]]:[&>input:focus-visible]:border-ring",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-4xl! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
|
||||
vertical:
|
||||
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-4xl! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div">) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(
|
||||
"flex items-center gap-2 rounded-4xl border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "button-group-text",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-4xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-transparent dark:hover:bg-input/30",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
|
||||
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,100 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-6 overflow-hidden rounded-4xl bg-card py-6 text-sm text-card-foreground shadow-md ring-1 ring-foreground/5 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 dark:ring-foreground/10 *:[img:first-child]:rounded-t-4xl *:[img:last-child]:rounded-b-4xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1.5 rounded-t-4xl px-6 group-data-[size=sm]/card:px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("font-heading text-base font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6 group-data-[size=sm]/card:px-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-4xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) return
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) return
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return
|
||||
onSelect(api)
|
||||
api.on("reInit", onSelect)
|
||||
api.on("select", onSelect)
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
data-slot="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden"
|
||||
data-slot="carousel-content"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-slot="carousel-item"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselPrevious({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon-sm",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute touch-manipulation rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -left-12 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselNext({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon-sm",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute touch-manipulation rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -right-12 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
useCarousel,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
|
||||
|
||||
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
} from "@/components/ui/input-group"
|
||||
import { SearchIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"flex size-full flex-col overflow-hidden rounded-4xl bg-popover p-1 text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = false,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"top-1/3 translate-y-0 overflow-hidden rounded-4xl! p-0",
|
||||
className
|
||||
)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
{children}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
||||
<InputGroup className="h-9 bg-input/50">
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className={cn("py-6 text-center text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"overflow-hidden p-1.5 text-foreground **:[[cmdk-group-heading]]:px-3 **:[[cmdk-group-heading]]:py-2 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("my-1.5 h-px bg-border/50", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"group/command-item relative flex cursor-default items-center gap-2 rounded-2xl px-3 py-2 text-sm font-medium outline-hidden select-none in-data-[slot=dialog-content]:rounded-3xl data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||
</CommandPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-4xl bg-popover p-6 text-sm text-popover-foreground shadow-xl ring-1 ring-foreground/5 duration-100 outline-none sm:max-w-md dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-4 right-4 bg-secondary"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-48 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-3xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-3 py-2.5 text-xs text-muted-foreground data-inset:pl-9.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-2.5 rounded-2xl px-3 py-2 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-9.5 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-2xl px-3 py-2 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-9.5 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("w-auto min-w-36 rounded-3xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2.5 rounded-2xl py-2 pr-8 pl-3 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-9.5 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2.5 rounded-2xl py-2 pr-8 pl-3 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-9.5 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1.5 my-1.5 h-px bg-border/50", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client"
|
||||
|
||||
import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
|
||||
return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 4,
|
||||
...props
|
||||
}: PreviewCardPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PreviewCardPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<PreviewCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<PreviewCardPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PreviewCardPrimitive.Popup
|
||||
data-slot="hover-card-content"
|
||||
className={cn(
|
||||
"z-50 w-72 origin-(--transform-origin) rounded-3xl bg-popover p-4 text-sm text-popover-foreground shadow-lg ring-1 ring-foreground/5 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PreviewCardPrimitive.Positioner>
|
||||
</PreviewCardPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group relative flex h-9 w-full min-w-0 items-center rounded-4xl border border-transparent bg-input/50 transition-[color,box-shadow,background-color] outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-data-[align=block-end]:rounded-3xl has-data-[align=block-start]:rounded-3xl has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/30 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[textarea]:rounded-2xl has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"flex h-auto cursor-text items-center justify-center gap-2 py-2 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 **:data-[slot=kbd]:rounded-3xl **:data-[slot=kbd]:bg-muted-foreground/10 **:data-[slot=kbd]:px-1.5 [&>svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start": "order-first pl-3 has-[>button]:-ml-1 has-[>kbd]:-ml-1",
|
||||
"inline-end": "order-last pr-3 has-[>button]:-mr-1 has-[>kbd]:-mr-1",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-3.5 [.border-b]:pb-3.5",
|
||||
"block-end":
|
||||
"order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-3.5 [.border-t]:pt-3.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"flex items-center gap-2 rounded-4xl text-sm shadow-none",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 rounded-xl px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "",
|
||||
"icon-xs": "size-6 rounded-xl p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
|
||||
VariantProps<typeof inputGroupButtonVariants> & {
|
||||
type?: "button" | "submit" | "reset"
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-2.5 shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-3xl border border-transparent bg-input/50 px-3 py-1 text-base transition-[color,box-shadow,background-color] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: PopoverPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PopoverPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
className={cn(
|
||||
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-4 rounded-3xl bg-popover p-4 text-sm text-popover-foreground shadow-lg ring-1 ring-foreground/5 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Positioner>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-1 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Title
|
||||
data-slot="popover-title"
|
||||
className={cn("text-base font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: PopoverPrimitive.Description.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Description
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
children,
|
||||
value,
|
||||
...props
|
||||
}: ProgressPrimitive.Root.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
value={value}
|
||||
data-slot="progress"
|
||||
className={cn("flex flex-wrap gap-3", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ProgressTrack>
|
||||
<ProgressIndicator />
|
||||
</ProgressTrack>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Track
|
||||
className={cn(
|
||||
"relative flex h-3 w-full items-center overflow-x-hidden rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
data-slot="progress-track"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressIndicator({
|
||||
className,
|
||||
...props
|
||||
}: ProgressPrimitive.Indicator.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className={cn("h-full bg-primary transition-all", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Label
|
||||
className={cn("text-sm font-medium", className)}
|
||||
data-slot="progress-label"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Value
|
||||
className={cn(
|
||||
"ml-auto text-sm text-muted-foreground tabular-nums",
|
||||
className
|
||||
)}
|
||||
data-slot="progress-value"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Progress,
|
||||
ProgressTrack,
|
||||
ProgressIndicator,
|
||||
ProgressLabel,
|
||||
ProgressValue,
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Root.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Scrollbar.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1.5 p-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-3xl border border-transparent bg-input/50 px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow,background-color] outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-3xl bg-popover text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-3 py-2.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2.5 rounded-2xl py-2 pr-8 pl-3 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn(
|
||||
"pointer-events-none -mx-1.5 my-1.5 h-px bg-border",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Backdrop
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/30 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: SheetPrimitive.Popup.Props & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Popup
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col bg-popover bg-clip-padding text-sm text-popover-foreground shadow-xl transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-4 right-4 bg-secondary"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: SheetPrimitive.Description.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
dir,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
dir={dir}
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer hidden text-sidebar-foreground md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-2xl group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-2xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("h-8 w-full bg-input/50 shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn(
|
||||
"flex flex-col gap-2 p-2 [--radius:var(--radius-xl)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"no-scrollbar flex min-h-0 flex-1 flex-col gap-2 overflow-auto [--radius:var(--radius-xl)] group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(
|
||||
"flex h-8 shrink-0 items-center rounded-xl px-3 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-group-label",
|
||||
sidebar: "group-label",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
|
||||
return useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(
|
||||
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-xl p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-group-action",
|
||||
sidebar: "group-action",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-0.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-xl px-3 py-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 text-sm",
|
||||
sm: "h-8 text-xs",
|
||||
lg: "h-14 px-3 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
render,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> &
|
||||
React.ComponentProps<"button"> & {
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const { isMobile, state } = useSidebar()
|
||||
const comp = useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render: !tooltip ? render : <TooltipTrigger render={render} />,
|
||||
state: {
|
||||
slot: "sidebar-menu-button",
|
||||
sidebar: "menu-button",
|
||||
size,
|
||||
active: isActive,
|
||||
},
|
||||
})
|
||||
|
||||
if (!tooltip) {
|
||||
return comp
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
{comp}
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
render,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> &
|
||||
React.ComponentProps<"button"> & {
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
return useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(
|
||||
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-xl p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-2 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-menu-action",
|
||||
sidebar: "menu-action",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-xl px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const [width] = React.useState(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-xl px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-xl"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
render,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: useRender.ComponentProps<"a"> &
|
||||
React.ComponentProps<"a"> & {
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
return useRender({
|
||||
defaultTagName: "a",
|
||||
props: mergeProps<"a">(
|
||||
{
|
||||
className: cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-xl px-3 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-menu-sub-button",
|
||||
sidebar: "menu-sub-button",
|
||||
size,
|
||||
active: isActive,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-2xl bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,10 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Loader2Icon } from "lucide-react"
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<Loader2Icon role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Spinner }
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: SwitchPrimitive.Root.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border-2 transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-5 data-[size=default]:w-11 data-[size=sm]:h-4 data-[size=sm]:w-7 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-unchecked:border-transparent data-unchecked:bg-input/90 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block rounded-full bg-background shadow-sm ring-0 transition-transform not-dark:bg-clip-padding group-data-[size=default]/switch:h-4 group-data-[size=default]/switch:w-6 group-data-[size=sm]/switch:h-3 group-data-[size=sm]/switch:w-4 data-checked:translate-x-[calc(100%-8px)] dark:data-checked:bg-primary-foreground data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: TabsPrimitive.Root.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-full p-1 text-muted-foreground group-data-horizontal/tabs:h-9 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col group-data-vertical/tabs:rounded-2xl data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-2 rounded-full border border-transparent! px-3 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start group-data-vertical/tabs:rounded-2xl group-data-vertical/tabs:px-3 group-data-vertical/tabs:py-1.5 hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Panel
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full resize-none rounded-2xl border border-transparent bg-input/50 px-3 py-3 text-base transition-[color,box-shadow,background-color] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-xl bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-lg data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:translate-x-[1.5px] data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:translate-x-[-1.5px] data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:translate-x-[-1.5px] data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:translate-x-[1.5px] data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Generated
+7818
-108
File diff suppressed because it is too large
Load Diff
+28
-1
@@ -9,9 +9,36 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@rive-app/react-webgl2": "^4.28.6",
|
||||
"@streamdown/cjk": "^1.0.3",
|
||||
"@streamdown/code": "^1.1.1",
|
||||
"@streamdown/math": "^1.0.2",
|
||||
"@streamdown/mermaid": "^1.0.2",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"ai": "^6.0.193",
|
||||
"ansi-to-react": "^6.2.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.40.0",
|
||||
"lucide-react": "^1.17.0",
|
||||
"media-chrome": "^4.19.0",
|
||||
"motion": "^12.40.0",
|
||||
"nanoid": "^5.1.11",
|
||||
"next": "16.2.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"react-dom": "19.2.4",
|
||||
"react-jsx-parser": "^2.4.1",
|
||||
"shadcn": "^4.8.3",
|
||||
"shiki": "^4.1.0",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tokenlens": "^1.3.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"use-stick-to-bottom": "^1.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user