This commit is contained in:
2026-05-14 19:24:50 +00:00
parent af0628867f
commit a7f218e182
63 changed files with 10539 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
import { useEffect, useState } from 'react';
import { api } from '@/api/client';
import type { AvailableProject } from '@/api/types';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdded: () => void;
}
export function AddProjectModal({ open, onOpenChange, onAdded }: Props) {
const [available, setAvailable] = useState<AvailableProject[] | null>(null);
const [customPath, setCustomPath] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!open) return;
setError(null);
setCustomPath('');
setAvailable(null);
api.projects
.available()
.then(setAvailable)
.catch((err) =>
setError(err instanceof Error ? err.message : 'failed to list available projects')
);
}, [open]);
async function add(path: string) {
setBusy(true);
setError(null);
try {
await api.projects.add({ path });
onAdded();
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'failed to add');
} finally {
setBusy(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add project</DialogTitle>
<DialogDescription>
Pick from detected repos in /opt or type a path.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="rounded-md border max-h-64 overflow-y-auto">
{available === null && (
<div className="px-3 py-2 text-sm text-muted-foreground">Loading</div>
)}
{available && available.length === 0 && (
<div className="px-3 py-2 text-sm text-muted-foreground">
No undiscovered repos in /opt.
</div>
)}
{available?.map((p) => (
<button
key={p.path}
disabled={busy}
onClick={() => void add(p.path)}
className="w-full text-left px-3 py-2 hover:bg-muted disabled:opacity-50 border-b last:border-b-0"
>
<div className="text-sm font-medium">{p.name}</div>
<div className="text-xs text-muted-foreground font-mono">{p.path}</div>
</button>
))}
</div>
<div className="space-y-1.5">
<Label htmlFor="custom-path">Custom path</Label>
<div className="flex gap-2">
<Input
id="custom-path"
placeholder="/opt/some-repo"
value={customPath}
onChange={(e) => setCustomPath(e.target.value)}
disabled={busy}
/>
<Button
onClick={() => void add(customPath.trim())}
disabled={busy || !customPath.trim()}
>
Add
</Button>
</div>
</div>
{error && (
<div className="text-sm text-destructive">{error}</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,58 @@
import { useState, type KeyboardEvent } from 'react';
import { Send } from 'lucide-react';
import { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
interface Props {
disabled?: boolean;
onSend: (content: string) => void | Promise<void>;
}
export function ChatInput({ disabled, onSend }: Props) {
const [value, setValue] = useState('');
const [busy, setBusy] = useState(false);
async function submit() {
const text = value.trim();
if (!text || disabled || busy) return;
setBusy(true);
try {
await onSend(text);
setValue('');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to send');
} finally {
setBusy(false);
}
}
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
void submit();
}
}
return (
<div className="border-t px-4 py-3 flex items-end gap-2">
<Textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Ask about this project. Cmd/Ctrl+Enter to send."
disabled={disabled || busy}
rows={3}
className="resize-none min-h-[68px] max-h-[240px]"
/>
<Button
onClick={() => void submit()}
disabled={disabled || busy || !value.trim()}
size="icon-lg"
aria-label="Send"
>
<Send />
</Button>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { useState } from 'react';
import { Check, Copy } from 'lucide-react';
// NOTE: spec calls for syntax-highlighted code blocks. Highlighting deferred
// to keep dep footprint minimal; this renders styled mono code with a copy
// button. Adding a highlighter (shiki / highlight.js) is a one-import swap.
interface Props {
code: string;
lang?: string;
}
export function CodeBlock({ code, lang }: Props) {
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {
/* ignore */
}
}
return (
<div className="rounded-md border bg-muted/40 overflow-hidden text-sm my-1">
<div className="flex items-center justify-between px-2 py-1 border-b text-xs text-muted-foreground">
<span className="font-mono">{lang || 'code'}</span>
<button
type="button"
onClick={() => void copy()}
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground"
aria-label="Copy code"
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
<pre className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed">
{code}
</pre>
</div>
);
}
interface SegmentText {
kind: 'text';
value: string;
}
interface SegmentCode {
kind: 'code';
lang?: string;
value: string;
}
export type Segment = SegmentText | SegmentCode;
export function splitCodeBlocks(input: string): Segment[] {
const segments: Segment[] = [];
const fence = /```([a-zA-Z0-9_-]*)\n([\s\S]*?)```/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = fence.exec(input)) !== null) {
if (match.index > lastIndex) {
segments.push({ kind: 'text', value: input.slice(lastIndex, match.index) });
}
segments.push({
kind: 'code',
lang: match[1] || undefined,
value: (match[2] ?? '').replace(/\n$/, ''),
});
lastIndex = match.index + match[0].length;
}
if (lastIndex < input.length) {
segments.push({ kind: 'text', value: input.slice(lastIndex) });
}
return segments;
}

View File

@@ -0,0 +1,56 @@
import type { Message } from '@/api/types';
import { ToolCallCard } from './ToolCallCard';
import { CodeBlock, splitCodeBlocks } from './CodeBlock';
interface Props {
message: Message;
}
export function MessageBubble({ message }: Props) {
if (message.role === 'tool') {
return <ToolCallCard message={message} />;
}
if (message.role === 'user') {
return (
<div className="flex justify-end">
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap">
{message.content}
</div>
</div>
);
}
const isStreaming = message.status === 'streaming';
const failed = message.status === 'failed';
return (
<div className="flex flex-col gap-2">
{message.tool_calls?.map((tc) => (
<ToolCallCard key={tc.id} toolCall={tc} />
))}
{(message.content.length > 0 || (!message.tool_calls?.length && isStreaming)) && (
<div className="max-w-[90%] text-sm leading-relaxed space-y-2">
{splitCodeBlocks(message.content).map((seg, i) =>
seg.kind === 'code' ? (
<CodeBlock key={i} code={seg.value} lang={seg.lang} />
) : (
<div key={i} className="whitespace-pre-wrap">
{seg.value}
{isStreaming && i === splitCodeBlocks(message.content).length - 1 && (
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse ml-0.5" />
)}
</div>
)
)}
{message.content.length === 0 && isStreaming && (
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
)}
</div>
)}
{failed && (
<div className="text-xs text-destructive">message failed</div>
)}
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { useEffect, useRef } from 'react';
import type { Message } from '@/api/types';
import { MessageBubble } from './MessageBubble';
interface Props {
messages: Message[];
}
export function MessageList({ messages }: Props) {
const endRef = useRef<HTMLDivElement>(null);
useEffect(() => {
endRef.current?.scrollIntoView({ block: 'end' });
}, [messages]);
if (messages.length === 0) {
return (
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
Send a message to start.
</div>
);
}
return (
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{messages.map((m) => (
<MessageBubble key={m.id} message={m} />
))}
<div ref={endRef} />
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import { Check, ChevronDown } from 'lucide-react';
import { api } from '@/api/client';
import type { ModelInfo } from '@/api/types';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface Props {
value: string;
onChange: (model: string) => void | Promise<void>;
}
export function ModelPicker({ value, onChange }: Props) {
const [models, setModels] = useState<ModelInfo[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open || models !== null) return;
api.models()
.then(setModels)
.catch((err) =>
setError(err instanceof Error ? err.message : 'failed to load models')
);
}, [open, models]);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className="text-xs font-mono text-muted-foreground hover:text-foreground flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted/60"
>
{value}
<ChevronDown className="size-3 opacity-70" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-72 overflow-y-auto">
{error && (
<div className="px-2 py-1.5 text-xs text-destructive">{error}</div>
)}
{models === null && !error && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">Loading</div>
)}
{models?.map((m) => (
<DropdownMenuItem
key={m.id}
onSelect={() => void onChange(m.id)}
className="font-mono text-xs"
>
<Check
className={`size-3 ${m.id === value ? 'opacity-100' : 'opacity-0'}`}
/>
{m.id}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,97 @@
import { useState } from 'react';
import { NavLink, useNavigate } from 'react-router-dom';
import { Plus, Folder } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { AddProjectModal } from './AddProjectModal';
import { useProjects } from '@/hooks/useProjects';
export function ProjectSidebar() {
const { projects, refresh, remove } = useProjects();
const [addOpen, setAddOpen] = useState(false);
const navigate = useNavigate();
async function handleRemove(id: string) {
try {
await remove(id);
navigate('/');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to remove project');
}
}
return (
<aside className="w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen">
<div className="px-4 py-3 border-b flex items-center justify-between">
<NavLink to="/" className="font-semibold tracking-tight text-base">
BooCode
</NavLink>
<Button
size="icon-sm"
variant="ghost"
onClick={() => setAddOpen(true)}
aria-label="Add project"
>
<Plus />
</Button>
</div>
<nav className="flex-1 overflow-y-auto py-2">
{projects === null && (
<div className="px-4 py-2 text-xs text-muted-foreground">Loading</div>
)}
{projects && projects.length === 0 && (
<div className="px-4 py-2 text-xs text-muted-foreground">No projects yet.</div>
)}
{projects?.map((p) => (
<div key={p.id} className="px-2">
<DropdownMenu>
<NavLink
to={`/project/${p.id}`}
className={({ isActive }) =>
`group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm ${
isActive
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'hover:bg-sidebar-accent/60'
}`
}
onContextMenu={(e) => {
e.preventDefault();
(
e.currentTarget.parentElement?.querySelector(
'[data-ctxtrigger]'
) as HTMLElement | null
)?.click();
}}
>
<Folder className="size-3.5 shrink-0 opacity-70" />
<span className="truncate" title={p.path}>
{p.name}
</span>
</NavLink>
<DropdownMenuTrigger asChild>
<button data-ctxtrigger className="hidden" aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
variant="destructive"
onClick={() => void handleRemove(p.id)}
>
Remove from sidebar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</nav>
<AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={refresh} />
</aside>
);
}

View File

@@ -0,0 +1,60 @@
import { useState } from 'react';
import { ChevronRight, Wrench } from 'lucide-react';
import type { Message, ToolCall } from '@/api/types';
interface Props {
message?: Message;
toolCall?: ToolCall;
}
export function ToolCallCard({ message, toolCall }: Props) {
const [open, setOpen] = useState(false);
const tc = toolCall ?? message?.tool_calls?.[0];
const result = message?.tool_results;
const name = tc?.name ?? 'tool';
const args = tc?.args ?? {};
const error = result?.error;
const output = result?.output;
const truncated = result?.truncated;
return (
<div className="rounded-md border border-border bg-muted/30 text-sm overflow-hidden">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center gap-2 px-2.5 py-1.5 hover:bg-muted/60 text-left"
>
<ChevronRight
className={`size-3.5 transition-transform ${open ? 'rotate-90' : ''}`}
/>
<Wrench className="size-3.5 opacity-70" />
<span className="font-mono font-medium">{name}</span>
<span className="font-mono text-xs text-muted-foreground truncate min-w-0 flex-1">
{JSON.stringify(args)}
</span>
{error && (
<span className="text-xs text-destructive font-medium ml-2">error</span>
)}
{truncated && (
<span className="text-xs text-muted-foreground ml-2">truncated</span>
)}
</button>
{open && (
<div className="px-2.5 py-2 border-t bg-background/40">
{error ? (
<pre className="text-xs text-destructive font-mono whitespace-pre-wrap">
{error}
</pre>
) : output !== undefined ? (
<pre className="text-xs font-mono whitespace-pre-wrap overflow-x-auto max-h-72 overflow-y-auto">
{typeof output === 'string' ? output : JSON.stringify(output, null, 2)}
</pre>
) : (
<div className="text-xs text-muted-foreground">no result yet</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg 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/50 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 [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 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-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,166 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs 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
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
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-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm 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" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
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,
}

View File

@@ -0,0 +1,269 @@
"use client"
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 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 data-[state=closed]:overflow-hidden 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}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.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-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 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 DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 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"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 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"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", 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}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 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" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 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 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}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 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/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,29 @@
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme="dark"
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@@ -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 rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }