Add reusable multi-session skill and five-page project workbench

This commit is contained in:
aiagent
2026-10-19 03:03:29 -07:00
parent 939c14de77
commit 579dece104
75 changed files with 13096 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
*.local
.env
.env.*
!.env.example
*.log
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+67
View File
@@ -0,0 +1,67 @@
# Agent Workbench dashboard template
A reusable React + TypeScript dashboard using Vite, Tailwind CSS and official
shadcn/ui components. All project data comes from the same-origin API. No
production fixtures, accounts, host inventories, credentials or screenshots are
included.
## Build and serve
Use Node.js 22.12 or newer (validated with Node.js 24) and npm:
```sh
npm ci
npm run build
```
Serve `dist/index.html` at `/` and `dist/assets/*` with the coordination API at
`/api/*`. Route selection lives in the URL hash, so the static server does not
need an SPA path fallback. Do not serve source files or `node_modules`.
`npm run preview` only previews static assets; an authenticated API is still
required. `npm run lint` checks the source; upstream shadcn sources can emit
React development/fast-refresh warnings without affecting a production build.
The official Radix/sidebar components use inline styles for dimensions and
positioning. A compatible CSP keeps scripts and connections at `'self'` while
allowing inline styles. No remote fonts, CDN scripts or external runtime assets
are required. Retain the server's authentication, Host/Origin and CSRF checks.
## Pages and data
- `#/overview`: summary, current phase, blockers, important reports and decisions.
- `#/tasks`: work packages, phase, ETA range and evidence.
- `#/cluster`: node memory/load, storage and scheduler queues; unknown is not zero.
- `#/communication`: the configured current/dedicated conversation and other sessions.
- `#/decisions`: pending task decisions and proposal/decision events.
`GET /api/state` provides the title from `config.title` (or `title`), falling back
to `Agent Workbench`. `primary_session_id` selects the current or dedicated session and
falls back to `wp4-progress` for compatibility. The UI never assumes the bound
conversation is WP4. An optional backend-provided `session.thread_url` matching
`codex://threads/<UUID>` enables the local original-session link; the client never
fabricates a URI from an unverified ID. An absent primary record disables sending; it never
silently redirects to a different session. History messages with
`origin: session_history` are labeled as synchronized source text.
The client reads `GET /api/messages?session_id=...` and writes
`POST /api/messages` and `POST /api/session-requests`. It sends a Bearer token
and `X-CSRF-Token: 1`. The token remains in React memory, never URL, localStorage
or sessionStorage. Refresh requires re-login while preserving route and selected
session. Polling is every three seconds, with cancellation and session scoping.
A send retry reuses its idempotency key. History synchronization and AI execution
are backend/worker responsibilities; the template never generates model replies.
## Fixed design system and upstream sources
`src/index.css` is the only palette definition: official zinc base plus official
blue primary, with named semantic success/danger/active state colors. Pages use
semantic tokens, not randomly selected per-project palettes.
The official `sidebar-07` and `login-01` blocks were installed using the official
`shadcn@4.22.1` CLI. Their project-specific adapters are `app-sidebar.tsx`,
`nav-main.tsx` and `login-form.tsx`. Underlying UI components are retained in
`src/components/ui`. Normal builds use the committed source snapshot and exact
npm lockfile; they do not fetch a registry or run the generator.
See `THIRD_PARTY_NOTICES.md`, `THIRD_PARTY_LICENSES.txt` and
`registry-provenance.json` for sources, licenses and content hashes.
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
# Third-party notices
The template uses the official shadcn/ui **sidebar-07** and **login-01** blocks,
installed with `shadcn@4.22.1 add` from the official `new-york-v4` registry.
The sidebar block is adapted for five flat React Router pages and a project title;
the login block is adapted for a token-only same-origin API. Sample accounts,
teams, projects, and external login actions have been removed.
- Blocks: https://ui.shadcn.com/r/styles/new-york-v4/sidebar-07.json and
https://ui.shadcn.com/r/styles/new-york-v4/login-01.json
- Components installed by the same official CLI: card, badge, textarea, progress,
alert, select, button, separator, sheet, tooltip, input, skeleton, breadcrumb,
collapsible, dropdown-menu, avatar, label, sidebar, field, and use-mobile hook.
- Fixed zinc base: https://ui.shadcn.com/r/colors/zinc.json
- Official blue primary theme: https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/themes.ts
- Source: https://github.com/shadcn-ui/ui
`registry-provenance.json` records the upstream registry/theme content hashes.
The vendored component sources and npm lockfile are the reproducible source of
truth; a normal build never fetches a mutable registry or runs shadcn CLI.
The code-generation CLI is not a build or runtime dependency.
Core dependencies: React/React DOM (MIT), React Router (MIT), Tailwind CSS (MIT),
Vite (MIT), Radix UI (MIT), class-variance-authority (Apache-2.0), cn (MIT),
Lucide (ISC), tw-animate-css (MIT). Exact versions are in package-lock.json.
Full installed production dependency license texts are preserved in
`THIRD_PARTY_LICENSES.txt`; see each package for its authoritative copyright.
## shadcn/ui license
MIT License
Copyright (c) 2023 shadcn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {"config": "", "css": "src/index.css", "baseColor": "zinc", "cssVariables": true, "prefix": ""},
"iconLibrary": "lucide",
"aliases": {"components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks"}
}
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="color-scheme" content="light" /><title>Agent Workbench</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
+4333
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "agent-workbench-dashboard",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"class-variance-authority": "0.7.1",
"cn": "0.5.0",
"lucide-react": "1.59.0",
"radix-ui": "1.8.1",
"react": "19.3.1",
"react-dom": "19.3.1",
"react-router-dom": "7.18.4",
"tw-animate-css": "1.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.3.3",
"@types/node": "24.19.2",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.2",
"oxlint": "1.88.0",
"tailwindcss": "4.3.3",
"typescript": "7.0.2",
"vite": "8.3.4"
},
"engines": {
"node": ">=22.12.0"
}
}
+45
View File
@@ -0,0 +1,45 @@
{
"cli": "shadcn@4.22.1",
"registry_style": "new-york-v4",
"blocks": {
"sidebar-07": {
"url": "https://ui.shadcn.com/r/styles/new-york-v4/sidebar-07.json",
"sha256": "54789cc67edf51a0da25a80c4d68910373edb4354142fdc6dcb2c059aed5f11c"
},
"login-01": {
"url": "https://ui.shadcn.com/r/styles/new-york-v4/login-01.json",
"sha256": "0a5a08bc413d6de0b4cb3eeb575f6fd925c9f547fae2c787417ac1c66bc95118"
}
},
"themes": {
"zinc": {
"url": "https://ui.shadcn.com/r/colors/zinc.json",
"sha256": "5001b8c2474e7a865d9176c07ad34623f3f4b1836aac14cbe9be817dd200d374"
},
"blue": {
"url": "https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/themes.ts",
"sha256": "60cb5ed25f7b39d9973e2280f476dd2119a4268317f45bd7ee3cac7883db8ea0"
}
},
"installed_components": {
"src/components/ui/alert.tsx": "d03dcae5cdb63c381c1144ca9a0c49ba0e46466967d7deb94662034042acb034",
"src/components/ui/avatar.tsx": "0c5c48b8a1dff4fdc5ff9b6cb79cc9cd138682b7d86868068f571befe9675e88",
"src/components/ui/badge.tsx": "13a9380dfff853ee730c14a52df49f22aecd31f40f2998d7c1e7ec458b6e1650",
"src/components/ui/breadcrumb.tsx": "c75fcd3d9e8470188bc0dd4b27a2b26fff3ad42b9c9c534a953dc090ae2cba33",
"src/components/ui/button.tsx": "79dd6f75f8136394442202d6b8b922fb269eaad0a5dba579397c9d5b41f893bb",
"src/components/ui/card.tsx": "ef8305b12c3112dab42a4708b41b1ebcc042fa5ab4c20f039da8d45b09ed5059",
"src/components/ui/collapsible.tsx": "c6598e5584f4258b5d9fefadbc5ad6a66888ad58729f4b634a0bb2d9c513188b",
"src/components/ui/dropdown-menu.tsx": "9b39f3c4f1164529b5feebe5c3189efe6ea38790e36a158b22e263e73b227a9f",
"src/components/ui/field.tsx": "7ab52746fd60d4e896203a9c904a7249e16470f5a93b8911b8b4d7ff3b483c9c",
"src/components/ui/input.tsx": "0c9457181f6ddc80969bcf854e92c362903a55b6e889fbef3fd85343ecc4af5b",
"src/components/ui/label.tsx": "b823de7355666bd6a6a2cf88a6630e436d021b38fdbef5b749fac0921b86f44d",
"src/components/ui/progress.tsx": "cf5de49b1c6d9ba6753d1d2f62820c543935cf426b8af8b49b277cfda6cc2c5b",
"src/components/ui/select.tsx": "962830436bd0b7ee50e556f0747e277d6a86869ed27d167516346b15f26309d8",
"src/components/ui/separator.tsx": "7d943e8c309f8d530f25433ffc9167f771e635f2eeb1c3b55fa16955068c1657",
"src/components/ui/sheet.tsx": "ea473d38a26a9b0b879e5a13139899ad45e9782e896b3fa205c7763a6c506876",
"src/components/ui/sidebar.tsx": "9d503f3a07ba1101e527bb697216e747a37bc2a7ab1eaf92683bf8d1773ca7d2",
"src/components/ui/skeleton.tsx": "f51ce0fcd31e5c31437382e80ea0af94b4fc3d64221d27091d36824546780a43",
"src/components/ui/textarea.tsx": "5cafcfeeee29534f449387a16b585f5923d2faab1a0cdc50a874416b175f59ce",
"src/components/ui/tooltip.tsx": "908d99c8aaea79508c94b48c3278a08856f32d1d4ce531860fde038fdacb7c9d"
}
}
+36
View File
@@ -0,0 +1,36 @@
import { useCallback, useEffect, useState } from 'react'
import { HashRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
import { Blocks, Circle } from 'lucide-react'
import { AppSidebar, navigation } from '@/components/app-sidebar'
import { LoginForm } from '@/components/login-form'
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
import { Separator } from '@/components/ui/separator'
import { TooltipProvider } from '@/components/ui/tooltip'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb'
import { Workbench } from '@/lib/context'
import { request, errorText } from '@/lib/api'
import type { State } from '@/lib/types'
import { projectTitle, date } from '@/lib/types'
import Overview from '@/pages/overview'
import Tasks from '@/pages/tasks'
import Cluster from '@/pages/cluster'
import Communication from '@/pages/communication'
import Decisions from '@/pages/decisions'
function Shell({ token, initial, logout }: { token: string; initial: State; logout: () => void }) {
const [state, setState] = useState(initial), [error, setError] = useState('')
const title = projectTitle(state), location = useLocation()
const refresh = useCallback(async () => { const latest = await request<State>(token, '/api/state'); setState(latest); setError('') }, [token])
useEffect(() => {
const controller = new AbortController(); let busy = false
const poll = async () => { if (busy) return; busy = true; try { const latest = await request<State>(token, '/api/state', undefined, controller.signal); if (!controller.signal.aborted) { setState(latest); setError('') } } catch (e) { if (!controller.signal.aborted) setError(errorText(e)) } finally { busy = false } }
const timer = setInterval(() => void poll(), 3000)
return () => { clearInterval(timer); controller.abort() }
}, [token])
useEffect(() => { document.title = `${navigation.find(n => n.url === location.pathname)?.title || '总览'} · ${title}` }, [title, location.pathname])
return <Workbench.Provider value={{ state, token, refresh }}><SidebarProvider><AppSidebar title={title} onLogout={logout} /><SidebarInset><header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b px-4 md:px-6"><div className="flex items-center gap-3"><SidebarTrigger aria-label="切换导航" /><Separator orientation="vertical" className="h-4" /><Breadcrumb><BreadcrumbList><BreadcrumbItem><BreadcrumbPage>{navigation.find(n => n.url === location.pathname)?.title || '总览'}</BreadcrumbPage></BreadcrumbItem></BreadcrumbList></Breadcrumb></div><div className="flex items-center gap-2 text-xs text-muted-foreground"><Circle className={`size-2 fill-current ${error ? 'text-destructive' : 'text-primary'}`} /><span>{error ? '连接中断' : '实时同步'}</span><span className="hidden lg:inline">{date(state.updated)}</span></div></header><main className="min-w-0 flex-1 space-y-5 bg-muted/25 p-4 md:p-6 lg:p-8">{error && <Alert variant="destructive"><AlertDescription>{error} 当前显示上次同步的数据。</AlertDescription></Alert>}<Routes><Route path="/overview" element={<Overview />} /><Route path="/tasks" element={<Tasks />} /><Route path="/cluster" element={<Cluster />} /><Route path="/communication" element={<Communication />} /><Route path="/decisions" element={<Decisions />} /><Route path="*" element={<Navigate to="/overview" replace />} /></Routes></main><footer className="border-t px-6 py-4 text-xs text-muted-foreground">Agent Workbench · 每 3 秒同步 · AI 接入状态以会话显示为准</footer></SidebarInset></SidebarProvider></Workbench.Provider>
}
export default function App() {
const [auth, setAuth] = useState<{ token: string; state: State } | null>(null)
return <HashRouter><TooltipProvider>{auth ? <Shell token={auth.token} initial={auth.state} logout={() => setAuth(null)} /> : <main className="flex min-h-svh flex-col items-center justify-center gap-6 bg-muted/40 p-5"><div className="flex items-center gap-3 text-lg font-semibold"><span className="rounded-lg bg-primary p-2 text-primary-foreground"><Blocks className="size-5" /></span>Agent Workbench</div><div className="w-full max-w-md"><LoginForm onLogin={(token, state) => setAuth({ token, state })} /></div></main>}</TooltipProvider></HashRouter>
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,9 @@
// Adapted from official shadcn/ui sidebar-07 (MIT); demo identities removed.
import type { ComponentProps } from 'react'
import { Blocks, LayoutDashboard, ListTodo, Server, MessagesSquare, GitBranch, LogOut } from 'lucide-react'
import { NavMain } from '@/components/nav-main'
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarRail, SidebarMenu, SidebarMenuItem, SidebarMenuButton } from '@/components/ui/sidebar'
export const navigation = [{ title: '总览', url: '/overview', icon: LayoutDashboard }, { title: '任务', url: '/tasks', icon: ListTodo }, { title: '集群', url: '/cluster', icon: Server }, { title: '沟通', url: '/communication', icon: MessagesSquare }, { title: '决策', url: '/decisions', icon: GitBranch }]
export function AppSidebar({ title, onLogout, ...props }: ComponentProps<typeof Sidebar> & { title: string; onLogout: () => void }) {
return <Sidebar collapsible="icon" {...props}><SidebarHeader><SidebarMenu><SidebarMenuItem><SidebarMenuButton size="lg" asChild><a href="#/overview"><div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"><Blocks className="size-4" /></div><div className="grid flex-1 text-left text-sm leading-tight"><span className="truncate font-semibold">{title}</span><span className="truncate text-xs text-muted-foreground">Agent Workbench</span></div></a></SidebarMenuButton></SidebarMenuItem></SidebarMenu></SidebarHeader><SidebarContent><NavMain items={navigation} /></SidebarContent><SidebarFooter><SidebarMenu><SidebarMenuItem><SidebarMenuButton onClick={onLogout} tooltip="退出登录"><LogOut /><span>退出登录</span></SidebarMenuButton></SidebarMenuItem></SidebarMenu></SidebarFooter><SidebarRail /></Sidebar>
}
@@ -0,0 +1,20 @@
// Adapted from official shadcn/ui login-01 (MIT): token-only local login.
import { useState } from 'react'
import type { FormEvent } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { request, errorText } from '@/lib/api'
import type { State } from '@/lib/types'
export function LoginForm({ onLogin }: { onLogin: (token: string, state: State) => void }) {
const [token, setToken] = useState(''), [busy, setBusy] = useState(false), [error, setError] = useState('')
async function submit(event: FormEvent) {
event.preventDefault(); setBusy(true); setError('')
try { const value = token.trim(); const state = await request<State>(value, '/api/state'); setToken(''); onLogin(value, state) }
catch (e) { setError(errorText(e)) }
finally { setBusy(false) }
}
return <div className="flex flex-col gap-6"><Card><CardHeader><CardTitle className="text-xl">登录项目工作台</CardTitle><CardDescription>查看进度、监控资源,在专门会话中讨论方案。</CardDescription></CardHeader><CardContent><form onSubmit={submit}><FieldGroup><Field><FieldLabel htmlFor="access-token">访问令牌</FieldLabel><Input id="access-token" type="password" autoComplete="off" minLength={32} maxLength={4096} required value={token} onChange={e => setToken(e.target.value)} /><FieldDescription>令牌仅保留在当前页面内存;刷新后需重新登录。</FieldDescription></Field>{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}<Field><Button type="submit" disabled={busy}>{busy ? '正在登录…' : '登录'}</Button><FieldDescription className="text-center">页面位置保留,凭据不写入网址或浏览器存储。</FieldDescription></Field></FieldGroup></form></CardContent></Card></div>
}
@@ -0,0 +1,8 @@
// Adapted from official shadcn/ui sidebar-07 (MIT); see THIRD_PARTY_NOTICES.md.
import { NavLink } from 'react-router-dom'
import type { LucideIcon } from 'lucide-react'
import { SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '@/components/ui/sidebar'
export function NavMain({ items }: { items: { title: string; url: string; icon: LucideIcon }[] }) {
const { isMobile, setOpenMobile } = useSidebar()
return <SidebarGroup><SidebarGroupLabel>项目工作台</SidebarGroupLabel><SidebarMenu>{items.map(item => <SidebarMenuItem key={item.url}><NavLink to={item.url} onClick={() => { if (isMobile) setOpenMobile(false) }}>{({ isActive }) => <SidebarMenuButton asChild tooltip={item.title} isActive={isActive} className="data-[active=true]:bg-sidebar-primary/10 data-[active=true]:text-sidebar-primary"><span><item.icon /><span>{item.title}</span></span></SidebarMenuButton>}</NavLink></SidebarMenuItem>)}</SidebarMenu></SidebarGroup>
}
@@ -0,0 +1,20 @@
import type { ReactNode } from 'react'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { date, statusLabels } from '@/lib/types'
import type { Event } from '@/lib/types'
export function PageHeading({ title, description, action }: { title: string; description: string; action?: ReactNode }) {
return <div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center"><div><h1 className="text-2xl font-semibold tracking-tight">{title}</h1><p className="mt-2 text-sm text-muted-foreground">{description}</p></div>{action}</div>
}
export function Status({ value }: { value: string }) {
const tone = ['failed', 'blocked', 'uncertain'].includes(value) ? 'status-danger' : value === 'completed' ? 'status-success' : ['running', 'claimed'].includes(value) ? 'status-active' : 'status-neutral'
return <Badge variant="outline" className={tone}>{statusLabels[value] || value}</Badge>
}
export function Empty({ children }: { children: ReactNode }) { return <p className="py-6 text-sm leading-7 text-muted-foreground">{children}</p> }
export function Panel({ title, description, children, className }: { title: string; description?: string; children: ReactNode; className?: string }) {
return <Card className={className}><CardHeader><CardTitle>{title}</CardTitle>{description && <CardDescription>{description}</CardDescription>}</CardHeader><CardContent>{children}</CardContent></Card>
}
export function Reports({ events, compact = false }: { events: Event[]; compact?: boolean }) {
if (!events.length) return <Empty>暂无记录。新的汇报会同步到这里。</Empty>
return <div className="divide-y">{events.map(event => <article key={event.id} className="py-4 first:pt-0 last:pb-0"><time className="text-xs text-muted-foreground">{date(event.created)}</time><p className={`mt-2 whitespace-pre-wrap wrap-anywhere text-sm leading-7 ${compact ? 'line-clamp-4' : ''}`}>{event.text}</p></article>)}</div>
}
@@ -0,0 +1,65 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
const alertVariants = cva(
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
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(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }
@@ -0,0 +1,108 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { Avatar as AvatarPrimitive } from "radix-ui"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
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 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,
AvatarBadge,
AvatarGroup,
AvatarGroupCount,
}
@@ -0,0 +1,47 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Slot } from "radix-ui"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 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/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
@@ -0,0 +1,108 @@
import * as React from "react"
import { cn } from "cn"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { Slot } from "radix-ui"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
@@ -0,0 +1,63 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Slot } from "radix-ui"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 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/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_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",
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 }
@@ -0,0 +1,91 @@
import * as React from "react"
import { cn } from "cn"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", 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", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
@@ -0,0 +1,33 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
@@ -0,0 +1,254 @@
import * as React from "react"
import { cn } from "cn"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
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,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-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(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent 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",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</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,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent 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",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</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-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
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",
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-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</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-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
@@ -0,0 +1,246 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-sm leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
@@ -0,0 +1,20 @@
import * as React from "react"
import { cn } from "cn"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground 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 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "cn"
import { Label as LabelPrimitive } from "radix-ui"
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 }
@@ -0,0 +1,28 @@
import * as React from "react"
import { cn } from "cn"
import { Progress as ProgressPrimitive } from "radix-ui"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }
@@ -0,0 +1,189 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive 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-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md 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]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent 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 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "cn"
import { Separator as SeparatorPrimitive } from "radix-ui"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
@@ -0,0 +1,142 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", 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-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
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 { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { PanelLeftIcon } from "lucide-react"
import { Slot } from "radix-ui"
import { useIsMobile } from "@/hooks/use-mobile"
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,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type 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}>
<TooltipProvider delayDuration={0}>
<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>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...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
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"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--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 h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm"
>
{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"
className={cn("size-7", 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 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex",
"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-xl 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-background 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", 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(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto 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,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
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-1", 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 flex w-full items-center gap-2 overflow-hidden rounded-md p-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-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot.Root : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
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-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
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-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
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({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-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 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
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 "cn"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props}
/>
)
}
export { Skeleton }
@@ -0,0 +1,17 @@
import * as React from "react"
import { cn } from "cn"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
@@ -0,0 +1,54 @@
import * as React from "react"
import { cn } from "cn"
import { Tooltip as TooltipPrimitive } from "radix-ui"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=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" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
+131
View File
@@ -0,0 +1,131 @@
/* Fixed theme: official shadcn zinc base + blue theme. See THIRD_PARTY_NOTICES.md. */
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--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.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--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.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.546 0.245 262.881);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
--status-success: oklch(0.448 0.119 151.328);
--status-success-bg: oklch(0.962 0.044 156.743);
--status-danger: oklch(0.444 0.177 26.899);
--status-danger-bg: oklch(0.971 0.013 17.38);
--status-active: var(--primary);
--status-active-bg: oklch(0.97 0.014 254.604);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.424 0.199 265.638);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--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.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.623 0.214 259.815);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-status-success: var(--status-success);
--color-status-success-bg: var(--status-success-bg);
--color-status-danger: var(--status-danger);
--color-status-danger-bg: var(--status-danger-bg);
--color-status-active: var(--status-active);
--color-status-active-bg: var(--status-active-bg);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@layer base {
* { @apply border-border outline-ring/50; }
body { @apply bg-background text-foreground; font-family: ui-sans-serif, system-ui, sans-serif; }
button:not(:disabled), [role="button"]:not([aria-disabled="true"]) { cursor: pointer; }
}
@layer utilities {
.status-success { color: var(--status-success); background: var(--status-success-bg); border-color: transparent; }
.status-danger { color: var(--status-danger); background: var(--status-danger-bg); border-color: transparent; }
.status-active { color: var(--status-active); background: var(--status-active-bg); border-color: transparent; }
.status-neutral { color: var(--muted-foreground); background: var(--muted); border-color: transparent; }
}
+13
View File
@@ -0,0 +1,13 @@
export class ApiError extends Error {
status: number
constructor(status: number) { super(status === 401 ? '登录已失效,请重新登录。' : status === 409 ? '状态已变化,请刷新后重试。' : '请求未完成,请检查输入或连接。'); this.status = status }
}
export async function request<T>(token: string, path: string, data?: unknown, signal?: AbortSignal): Promise<T> {
const response = await fetch(path, { method: data === undefined ? 'GET' : 'POST', cache: 'no-store', signal,
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'X-CSRF-Token': '1' },
body: data === undefined ? undefined : JSON.stringify(data) })
if (!response.ok) throw new ApiError(response.status)
return response.json() as Promise<T>
}
export function errorText(error: unknown) { return error instanceof Error ? error.message : '连接暂不可用,请稍后重试。' }
export function messageKey() { return Array.from(crypto.getRandomValues(new Uint8Array(16)), n => n.toString(16).padStart(2, '0')).join('') }
+5
View File
@@ -0,0 +1,5 @@
import { createContext, useContext } from 'react'
import type { State } from './types'
export type WorkbenchContext = { state: State; token: string; refresh: () => Promise<void> }
export const Workbench = createContext<WorkbenchContext | null>(null)
export function useWorkbench() { const context = useContext(Workbench); if (!context) throw new Error('Workbench provider required'); return context }
+14
View File
@@ -0,0 +1,14 @@
export type Task = { id: string; name: string; status: string; phase: string; eta_min_minutes?: number | null; eta_max_minutes?: number | null; eta_basis?: string; decision?: string; updated?: number }
export type Session = { id: string; name: string; wp?: string; role?: string; thread_id?: string; thread_url?: string; connection?: string; history_synced_at?: number }
export type Event = { id: number; kind: string; text: string; created: number; session_id?: string }
export type Message = { id: string; session_id: string; text: string; source: 'human' | 'agent'; status: string; created: number; origin?: string; reply_to?: string }
export type Sample<T> = { host?: string; status?: string; error?: string; collected_at?: string; data?: T | null }
export type NodeMetric = { memory_used_percent?: number; memory_total_bytes?: number; memory_available_bytes?: number; load_1m?: number; load_5m?: number; load_15m?: number }
export type Mount = { mount_target: string; used_percent?: number; total_bytes?: number; available_bytes?: number }
export type SchedulerData = { count?: number; by_state?: Record<string, number>; queues?: { name: string; state: string; pend?: number; run?: number }[] }
export type Metrics = { sampled_at?: number; status?: string; message?: string; data?: { status?: string; message?: string; collected_at?: string; nodes?: Sample<NodeMetric>[]; storage?: Sample<Mount[]>[]; schedulers?: Record<string, Record<string, Sample<SchedulerData>>> } }
export type State = { config?: { title?: string }; title?: string; primary_session_id?: string; tasks: Task[]; sessions: Session[]; events: Event[]; requests: { id: string; name: string; status: string }[]; metrics?: Metrics; updated?: number }
export const statusLabels: Record<string, string> = { queued: '排队', running: '进行中', waiting: '等待', blocked: '受阻', completed: '已完成', failed: '失败', cancelled: '已取消', claimed: '处理中', delivered: '已送达', uncertain: '待人工核查' }
export const phaseLabels: Record<string, string> = { planning: '规划', implementation: '实现', validation: '验证', integration: '集成', done: '完成' }
export function date(value?: string | number) { return value == null ? '暂无' : new Date(typeof value === 'number' ? value * 1000 : value).toLocaleString('zh-CN') }
export function projectTitle(state: State) { return state.config?.title || state.title || 'Agent Workbench' }
+5
View File
@@ -0,0 +1,5 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(<StrictMode><App /></StrictMode>)
+20
View File
@@ -0,0 +1,20 @@
import { Progress } from '@/components/ui/progress'
import { Badge } from '@/components/ui/badge'
import { PageHeading, Panel, Empty } from '@/components/shared'
import { useWorkbench } from '@/lib/context'
import { date } from '@/lib/types'
import type { Sample } from '@/lib/types'
const size = (n?: number) => typeof n === 'number' ? `${(n / 1073741824).toFixed(1)} GiB` : '暂无'
function Usage({ value, label }: { value?: number; label: string }) { return <div className="space-y-2"><div className="flex justify-between gap-2 text-sm"><span className="truncate">{label}</span><span className="font-medium tabular-nums">{typeof value === 'number' && Number.isFinite(value) ? `${value.toFixed(1)}%` : '暂无'}</span></div>{typeof value === 'number' && Number.isFinite(value) && <Progress value={Math.max(0, Math.min(100, value))} aria-label={label} className="h-2" />}</div> }
function Unavailable({ sample }: { sample: Sample<unknown> }) { return <p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">{sample.error || '等待采集,暂无数据。'}</p> }
export default function Cluster() {
const { state } = useWorkbench()
const metrics = state.metrics?.data
return <div className="space-y-6"><PageHeading title="集群资源与调度" description={`采集时间:${date(metrics?.collected_at || state.metrics?.sampled_at)}。缺失数据不按零使用量显示。`} />
{!metrics && <Panel title="监控状态"><Empty>{state.metrics?.message || '等待首次采集。'}</Empty></Panel>}
<section className="space-y-4"><h2 className="text-lg font-semibold">节点资源</h2><div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{metrics?.nodes?.map((s, i) => <Panel key={`${s.host}-${i}`} title={s.host || '节点'} description={date(s.collected_at)}>{s.status === 'ok' && s.data ? <div className="space-y-4"><Usage value={s.data.memory_used_percent} label="内存使用" /><p className="text-xs text-muted-foreground">可用 {size(s.data.memory_available_bytes)} / 总量 {size(s.data.memory_total_bytes)}</p><p className="text-sm">负载 <span className="tabular-nums">{[s.data.load_1m, s.data.load_5m, s.data.load_15m].map(n => n ?? '暂无').join(' / ')}</span><span className="ml-2 text-xs text-muted-foreground">1 / 5 / 15 分钟</span></p></div> : <Unavailable sample={s} />}</Panel>)}</div>{!metrics?.nodes?.length && <Empty>尚无节点采样。</Empty>}</section>
<section className="space-y-4"><h2 className="text-lg font-semibold">共享存储</h2><div className="grid gap-4 lg:grid-cols-2">{metrics?.storage?.map((s, i) => <Panel key={`${s.host}-${i}`} title={s.host || '共享存储'} description={date(s.collected_at)}>{s.status === 'ok' && s.data ? <div className="space-y-5">{s.data.map((m, j) => <div key={`${m.mount_target}-${j}`} className="space-y-2"><Usage value={m.used_percent} label={m.mount_target} /><p className="text-xs text-muted-foreground">可用 {size(m.available_bytes)} / 总量 {size(m.total_bytes)}</p></div>)}</div> : <Unavailable sample={s} />}</Panel>)}</div>{!metrics?.storage?.length && <Empty>尚无存储采样。</Empty>}</section>
<section className="space-y-4"><h2 className="text-lg font-semibold">调度与队列</h2><div className="grid gap-4 lg:grid-cols-2">{Object.entries(metrics?.schedulers || {}).map(([name, parts]) => <Panel key={name} title={`${name.toUpperCase()} 调度`}><div className="space-y-5">{Object.entries(parts).map(([key, s]) => <div key={key}><h3 className="mb-2 text-sm font-medium">{{ nodes: '节点', hosts: '主机', queues: '队列', jobs: '可见作业' }[key] || key}</h3>{s.status === 'ok' && s.data ? <div className="space-y-3"><div className="flex flex-wrap items-center gap-2"><span className="text-sm">{s.data.count ?? '暂无'} 项</span>{Object.entries(s.data.by_state || {}).map(([label, n]) => <Badge key={label} variant="secondary">{label} {n}</Badge>)}</div>{s.data.queues?.map(q => <div key={q.name} className="flex flex-wrap justify-between gap-2 rounded-md border p-3 text-xs"><span>{q.name} · {q.state}</span><span>等待 {q.pend ?? '暂无'} / 运行 {q.run ?? '暂无'}</span></div>)}{key === 'queues' && <p className="text-xs text-muted-foreground">队列计数可能为槽位;可见作业计数按作业记录。</p>}</div> : <Unavailable sample={s} />}</div>)}</div></Panel>)}</div>{!Object.keys(metrics?.schedulers || {}).length && <Empty>尚无调度采样。</Empty>}</section>
<details className="rounded-xl border bg-card p-5"><summary className="cursor-pointer text-sm font-medium">诊断详情</summary><pre className="mt-4 max-h-80 overflow-auto whitespace-pre-wrap wrap-anywhere text-xs text-muted-foreground">{JSON.stringify(state.metrics || {}, null, 2)}</pre></details>
</div>
}
@@ -0,0 +1,83 @@
import { useEffect, useRef, useState } from 'react'
import type { FormEvent } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Send, RotateCcw, MessageSquare } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { PageHeading, Panel, Empty, Status } from '@/components/shared'
import { useWorkbench } from '@/lib/context'
import { request, errorText, messageKey } from '@/lib/api'
import type { Message } from '@/lib/types'
import { date } from '@/lib/types'
export default function Communication() {
const { state, token, refresh } = useWorkbench()
const primary = state.primary_session_id || 'wp4-progress'
const [params, setParams] = useSearchParams()
const selected = params.get('session') || primary
const session = state.sessions.find(s => s.id === selected)
const [messages, setMessages] = useState<{ session: string; items: Message[] }>({ session: '', items: [] })
const [drafts, setDrafts] = useState<Record<string, string>>({})
const [error, setError] = useState('')
const [sending, setSending] = useState(false)
const [requesting, setRequesting] = useState(false)
const [refreshVersion, setRefreshVersion] = useState(0)
const pending = useRef<{ session_id: string; text: string; idempotency_key: string } | null>(null)
const scroll = useRef<HTMLDivElement>(null)
const known = Boolean(session)
const originalUrl = session?.thread_url && /^codex:\/\/threads\/[a-f0-9-]{36}$/i.test(session.thread_url) ? session.thread_url : undefined
useEffect(() => {
if (!known) return
const controller = new AbortController()
let inFlight = false
const poll = async () => {
if (inFlight) return
inFlight = true
try {
const result = await request<{ messages: Message[] }>(token, `/api/messages?session_id=${encodeURIComponent(selected)}`, undefined, controller.signal)
if (!controller.signal.aborted) { setMessages({ session: selected, items: result.messages }); setError('') }
} catch (e) { if (!controller.signal.aborted) setError(errorText(e)) }
finally { inFlight = false }
}
void poll()
const timer = setInterval(() => void poll(), 3000)
return () => { controller.abort(); clearInterval(timer) }
}, [selected, token, known, refreshVersion])
const items = messages.session === selected ? messages.items : []
const lastId = items.at(-1)?.id
useEffect(() => { const box = scroll.current; if (box && box.scrollHeight - box.scrollTop - box.clientHeight < 300) box.scrollTop = box.scrollHeight }, [lastId])
const text = drafts[selected] || ''
async function send(event: FormEvent) {
event.preventDefault()
if (!session || !text.trim() || sending) return
const sid = selected, body = text
if (!pending.current || pending.current.session_id !== sid || pending.current.text !== body) pending.current = { session_id: sid, text: body, idempotency_key: messageKey() }
setSending(true); setError('')
try {
await request(token, '/api/messages', { ...pending.current, source: 'human' })
pending.current = null
setDrafts(old => old[sid] === body ? { ...old, [sid]: '' } : old)
setRefreshVersion(n => n + 1)
await refresh()
} catch (e) { setError(errorText(e)) }
finally { setSending(false) }
}
async function newSession(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); const form = event.currentTarget, data = new FormData(form)
setRequesting(true)
try { await request(token, '/api/session-requests', { name: data.get('name'), wp: data.get('wp'), prompt: data.get('prompt') }); form.reset(); await refresh() }
catch (e) { setError(errorText(e)) }
finally { setRequesting(false) }
}
return <div className="space-y-6"><PageHeading title="进度与方案沟通" description="当前绑定会话用于进度询问、汇报和方案讨论。" action={<Button variant="outline" onClick={() => setParams({})}><RotateCcw />返回默认会话</Button>} />
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_300px]"><Panel title="项目沟通" description="消息始终发送给当前选中的会话。"><div className="space-y-5">{originalUrl && <Button asChild variant="outline" size="sm"><a href={originalUrl}>在本机打开原会话</a></Button>}<div className="space-y-2"><Label htmlFor="session">当前会话</Label><Select value={selected} onValueChange={value => setParams({ session: value })}><SelectTrigger id="session" className="w-full"><SelectValue /></SelectTrigger><SelectContent>{!session && <SelectItem value={selected}>默认会话尚未登记</SelectItem>}{state.sessions.map(s => <SelectItem key={s.id} value={s.id}>{s.id === primary ? '默认会话 · ' : ''}{s.wp || s.role} · {s.name}</SelectItem>)}</SelectContent></Select></div>
<div className="flex items-start gap-3 rounded-lg bg-muted/60 p-4 text-sm"><MessageSquare className="mt-0.5 size-4 shrink-0 text-primary" /><div><p className="font-medium">{selected === primary ? '同一绑定会话的消息同步' : '当前工作会话'}</p><p className="mt-1 text-muted-foreground">{!session ? '等待登记。不会改投其他会话。' : session.connection === 'connected' ? '已接入真实 AI。' : '等待 AI 执行桥接接入;新消息将持久排队。'}</p>{session?.history_synced_at && <p className="mt-1 text-xs text-muted-foreground">原会话同步于 {date(session.history_synced_at)}</p>}</div></div>
<div ref={scroll} role="log" aria-label="会话消息" aria-live="polite" className="h-[min(48vh,480px)] min-h-64 space-y-4 overflow-y-auto rounded-lg border bg-muted/20 p-4">{items.length ? items.map(m => <article key={m.id} className={`rounded-lg border bg-card p-4 ${m.source === 'agent' ? 'border-l-2 border-l-primary' : ''}`}><div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground"><span className="font-medium text-foreground">{m.source === 'agent' ? 'Agent' : '人工'}</span>{m.origin === 'session_history' && <span>原会话同步</span>}<Status value={m.status} /><time>{date(m.created)}</time></div><p className="whitespace-pre-wrap wrap-anywhere text-sm leading-7">{m.text}</p></article>) : <Empty>{known ? '可以询问项目进度,或提出需要比较的方案。' : '默认会话接入后,在这里继续讨论。'}</Empty>}</div>
<form onSubmit={send} className="space-y-3"><Label htmlFor="message">发送消息</Label><Textarea id="message" value={text} onChange={e => setDrafts(old => ({ ...old, [selected]: e.target.value }))} placeholder="目前进展如何?有哪些方案需要我决定?" maxLength={32000} rows={3} required /><div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"><p className="text-xs text-muted-foreground">消息来源字段不证明人类批准。</p><Button type="submit" disabled={!known || sending || !text.trim()}><Send />{sending ? '发送中…' : '发送到当前会话'}</Button></div></form>
</div></Panel><Panel title="沟通与协作" description="进度、汇报、方案,集中在一个入口。"><p className="text-sm leading-7 text-muted-foreground">历史正文会保留原会话来源。历史人工消息只展示,不会重新进入执行队列。</p><details className="mt-6 border-t pt-5"><summary className="cursor-pointer text-sm font-medium">请求其他工作会话</summary><form onSubmit={newSession} className="mt-4 space-y-4"><div className="space-y-2"><Label htmlFor="name">会话名称</Label><Input id="name" name="name" required maxLength={200} /></div><div className="space-y-2"><Label htmlFor="wp">工作包</Label><Input id="wp" name="wp" maxLength={100} /></div><div className="space-y-2"><Label htmlFor="prompt">任务说明</Label><Textarea id="prompt" name="prompt" required maxLength={16000} /></div><Button variant="outline" disabled={requesting}>{requesting ? '登记中…' : '登记新建请求'}</Button></form><p className="mt-4 text-xs leading-6 text-muted-foreground">登记请求不会自动创建真实 AI 会话。</p>{state.requests.map(r => <p key={r.id} className="mt-3 text-xs text-muted-foreground">{r.name} · 等待接入</p>)}</details></Panel></div>
</div>
}
+14
View File
@@ -0,0 +1,14 @@
import { Link } from 'react-router-dom'
import { ArrowRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { PageHeading, Panel, Empty, Reports } from '@/components/shared'
import { useWorkbench } from '@/lib/context'
import { date } from '@/lib/types'
export default function Decisions() {
const { state } = useWorkbench()
const pending = state.tasks.filter(t => t.decision)
return <div className="space-y-6"><PageHeading title="方案与决策" description="将待决定事项与方案记录集中管理。" action={<Button asChild><Link to="/communication">进入专门会话讨论 <ArrowRight /></Link></Button>} />
<Panel title="等待决定" description="以下内容由工作包登记;聊天中的来源字段不代表人类批准。">{pending.length ? <div className="divide-y">{pending.map(t => <article key={t.id} className="py-5 first:pt-0 last:pb-0"><h3 className="font-medium">{t.id} · {t.name}</h3><p className="mt-3 whitespace-pre-wrap text-sm leading-7">{t.decision}</p><p className="mt-3 text-xs text-muted-foreground">更新于 {date(t.updated)}</p></article>)}</div> : <Empty>当前没有登记待决策事项。</Empty>}</Panel>
<Panel title="方案与决策记录"><Reports events={state.events.filter(e => /decision|proposal|方案|决策/.test(e.kind))} /></Panel>
</div>
}
+20
View File
@@ -0,0 +1,20 @@
import { ArrowRight, Activity, CheckCircle2, CircleHelp, Layers3 } from 'lucide-react'
import { Link } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { PageHeading, Panel, Empty, Reports, Status } from '@/components/shared'
import { useWorkbench } from '@/lib/context'
import { phaseLabels } from '@/lib/types'
export default function Overview() {
const { state } = useWorkbench()
const pending = state.tasks.filter(t => t.decision)
const blocked = state.tasks.filter(t => ['blocked', 'failed'].includes(t.status))
const lead = state.tasks.find(t => t.id === 'WP0')
const stats = [{ label: '工作包', value: state.tasks.length, icon: Layers3 }, { label: '进行中', value: state.tasks.filter(t => t.status === 'running').length, icon: Activity }, { label: '已完成', value: state.tasks.filter(t => t.status === 'completed').length, icon: CheckCircle2 }, { label: '待决定', value: pending.length, icon: CircleHelp }]
return <div className="space-y-6"><PageHeading title="项目总览" description="关键进展、当前阻塞,以及需要你决定的事项。" action={<Button asChild><Link to="/communication">询问进度 <ArrowRight /></Link></Button>} />
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">{stats.map(s => <Card key={s.label}><CardContent className="flex items-start justify-between pt-6"><div><p className="text-sm text-muted-foreground">{s.label}</p><p className="mt-2 text-3xl font-semibold tabular-nums">{s.value}</p></div><s.icon className="size-5 text-primary" /></CardContent></Card>)}</div>
<div className="grid gap-6 lg:grid-cols-2"><Panel title="当前阶段与阻塞" description="总控记录与需要关注的任务。">{lead ? <div className="mb-5 flex items-center gap-3"><span className="font-medium">{phaseLabels[lead.phase] || lead.phase}</span><Status value={lead.status} /></div> : <Empty>尚未登记总控阶段。</Empty>}{blocked.length ? <div className="space-y-3">{blocked.slice(0, 3).map(t => <p key={t.id} className="text-sm">{t.id} · {t.name}</p>)}</div> : <p className="text-sm text-muted-foreground">当前没有登记受阻任务。</p>}<Button variant="link" asChild className="mt-4 px-0"><Link to="/tasks">查看全部任务 <ArrowRight /></Link></Button></Panel>
<Panel title="等待你决定" description="待决策事项摘要。">{pending.length ? <div className="space-y-4">{pending.slice(0, 3).map(t => <div key={t.id}><p className="text-xs font-medium text-muted-foreground">{t.id} · {t.name}</p><p className="mt-1 line-clamp-3 whitespace-pre-wrap text-sm leading-6">{t.decision}</p></div>)}</div> : <Empty>当前没有登记待决策事项。</Empty>}<Button variant="link" asChild className="mt-4 px-0"><Link to="/decisions">查看方案与决策 <ArrowRight /></Link></Button></Panel></div>
<Panel title="最近重要汇报" description="保留关键结论,详细讨论进入专门会话。"><Reports events={state.events.filter(e => /report|summary|handoff/.test(e.kind)).slice(0, 3)} compact /></Panel>
</div>
}
+10
View File
@@ -0,0 +1,10 @@
import { PageHeading, Panel, Status, Empty, Reports } from '@/components/shared'
import { useWorkbench } from '@/lib/context'
import { phaseLabels, date } from '@/lib/types'
export default function Tasks() {
const { state } = useWorkbench()
return <div className="space-y-6"><PageHeading title="任务与工作包" description="每项工作的当前阶段、预计剩余时间和判断依据。" />
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">{state.tasks.map(t => <Panel key={t.id} title={`${t.id} · ${t.name}`}><div className="flex items-center justify-between gap-3"><Status value={t.status} /><span className="text-sm text-muted-foreground">{phaseLabels[t.phase] || t.phase}</span></div><dl className="mt-5 space-y-4 text-sm"><div><dt className="text-muted-foreground">预计剩余</dt><dd className="mt-1 font-medium">{t.eta_min_minutes == null ? '尚无可靠估计' : `${t.eta_min_minutes}–${t.eta_max_minutes} 分钟`}</dd></div>{t.eta_basis && <div><dt className="text-muted-foreground">估计依据</dt><dd className="mt-1 whitespace-pre-wrap leading-6">{t.eta_basis}</dd></div>}{t.decision && <div><dt className="text-muted-foreground">待决定</dt><dd className="mt-1 whitespace-pre-wrap leading-6">{t.decision}</dd></div>}</dl><p className="mt-5 text-xs text-muted-foreground">更新于 {date(t.updated)}</p></Panel>)}</div>{!state.tasks.length && <Panel title="工作包"><Empty>尚未登记工作包。</Empty></Panel>}
<details className="rounded-xl border bg-card p-6"><summary className="cursor-pointer text-sm font-medium">Agent 活动记录</summary><div className="mt-5"><Reports events={state.events} /></div></details>
</div>
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"paths": {"@/*": ["./src/*"]},
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+18
View File
@@ -0,0 +1,18 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
],
"compilerOptions": {
"paths": {
"@/*": [
"./src/*"
]
}
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import { fileURLToPath, URL } from 'node:url'
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
build: { sourcemap: false },
})