forked from xiaohei/taiji-pda-v0
初始提交:完整的API客户端对接和前端项目
This commit is contained in:
+27
@@ -0,0 +1,27 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Eye, EyeOff, Shield, Globe } from "lucide-react"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { useLanguage } from "@/contexts/language-context"
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const router = useRouter()
|
||||
const { language, setLanguage, t } = useLanguage()
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [credentials, setCredentials] = useState({
|
||||
email: "",
|
||||
password: "",
|
||||
})
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
title: "Taiji Super Admin",
|
||||
subtitle: "Platform Control Center",
|
||||
email: "Admin Email",
|
||||
emailPlaceholder: "admin@taiji.ai",
|
||||
password: "Password",
|
||||
passwordPlaceholder: "Enter admin password",
|
||||
signIn: "Sign In",
|
||||
signingIn: "Authenticating...",
|
||||
footer: "Protected system access only",
|
||||
securityNote: "All access attempts are logged and monitored",
|
||||
},
|
||||
zh: {
|
||||
title: "Taiji 超级管理员",
|
||||
subtitle: "平台控制中心",
|
||||
email: "管理员邮箱",
|
||||
emailPlaceholder: "admin@taiji.ai",
|
||||
password: "密码",
|
||||
passwordPlaceholder: "输入管理员密码",
|
||||
signIn: "登录",
|
||||
signingIn: "正在验证...",
|
||||
footer: "仅限授权系统访问",
|
||||
securityNote: "所有访问尝试均被记录和监控",
|
||||
},
|
||||
}
|
||||
|
||||
const text = translations[language]
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
// Simulate admin authentication
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
|
||||
// Store admin auth token
|
||||
localStorage.setItem("admin_token", "admin_authenticated")
|
||||
|
||||
router.push("/admin/dashboard")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4 relative overflow-hidden">
|
||||
{/* Background grid pattern */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
|
||||
|
||||
{/* Language selector - top right */}
|
||||
<div className="absolute top-6 right-6 z-10">
|
||||
<Select value={language} onValueChange={(value: "en" | "zh") => setLanguage(value)}>
|
||||
<SelectTrigger className="w-[140px] bg-card border-border">
|
||||
<Globe className="mr-2 h-4 w-4" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
<SelectItem value="zh">中文</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Card className="w-full max-w-md bg-card/95 backdrop-blur-sm border-border shadow-2xl relative z-10">
|
||||
<div className="p-8">
|
||||
{/* Logo and title */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary/10 mb-4">
|
||||
<Shield className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">{text.title}</h1>
|
||||
<p className="text-muted-foreground text-sm">{text.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{/* Login form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-foreground">
|
||||
{text.email}
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder={text.emailPlaceholder}
|
||||
value={credentials.email}
|
||||
onChange={(e) => setCredentials({ ...credentials, email: e.target.value })}
|
||||
required
|
||||
className="bg-background border-border text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-foreground">
|
||||
{text.password}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={text.passwordPlaceholder}
|
||||
value={credentials.password}
|
||||
onChange={(e) => setCredentials({ ...credentials, password: e.target.value })}
|
||||
required
|
||||
className="bg-background border-border text-foreground pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? text.signingIn : text.signIn}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 space-y-2">
|
||||
<p className="text-center text-xs text-muted-foreground">{text.footer}</p>
|
||||
<p className="text-center text-xs text-muted-foreground/70">{text.securityNote}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { DashboardLayout } from "@/components/dashboard-layout"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Bot, Cpu, MemoryStick, Zap } from "lucide-react"
|
||||
import { useLanguage } from "@/hooks/useLanguage"
|
||||
import { TaijiAPIClient } from "@/lib/api-client"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
|
||||
export default function AgentFactoryPage() {
|
||||
const { t } = useLanguage()
|
||||
const { toast } = useToast()
|
||||
const [selectedAgent, setSelectedAgent] = useState<any>(null)
|
||||
const [showDeployDialog, setShowDeployDialog] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [deployConfig, setDeployConfig] = useState({
|
||||
agentCount: 1,
|
||||
model: "gpt-4o-mini",
|
||||
serviceGateway: "", // 新增服务网关字段
|
||||
})
|
||||
const [platformAgents, setPlatformAgents] = useState<any[]>([])
|
||||
const [deployedCount, setDeployedCount] = useState(0)
|
||||
const [stats, setStats] = useState({ cpu: 0, memory: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
loadAgents()
|
||||
}, [])
|
||||
|
||||
const loadAgents = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await TaijiAPIClient.getPlatformAgents()
|
||||
if (result?.success && result.data?.data) {
|
||||
setPlatformAgents(result.data.data)
|
||||
}
|
||||
|
||||
// 加载已部署的Agent数量
|
||||
try {
|
||||
const agents = await TaijiAPIClient.getAgents(0, 100)
|
||||
if (Array.isArray(agents)) {
|
||||
setDeployedCount(agents.filter((a: any) => a.status === "active" || a.status === "running").length)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load deployed agents:", error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load platform agents:", error)
|
||||
toast({
|
||||
title: t("加载失败", "Load Failed"),
|
||||
description: t("无法加载Agent列表", "Failed to load agent list"),
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const defaultPlatformAgents = [
|
||||
{
|
||||
id: "weather-agent",
|
||||
name: t("天气查询Agent", "Weather Query Agent"),
|
||||
description: t("提供全球天气信息查询和预报服务", "Provide global weather information query and forecast service"),
|
||||
capabilities: [
|
||||
t("实时天气", "Real-time weather"),
|
||||
t("7天预报", "7-day forecast"),
|
||||
t("气象警告", "Weather alerts"),
|
||||
],
|
||||
status: "available",
|
||||
icon: "🌤️",
|
||||
},
|
||||
{
|
||||
id: "data-analysis-agent",
|
||||
name: t("数据分析Agent", "Data Analysis Agent"),
|
||||
description: t("执行数据分析、可视化和报告生成", "Perform data analysis, visualization and report generation"),
|
||||
capabilities: [
|
||||
t("数据清洗", "Data cleaning"),
|
||||
t("统计分析", "Statistical analysis"),
|
||||
t("图表生成", "Chart generation"),
|
||||
],
|
||||
status: "available",
|
||||
icon: "📊",
|
||||
},
|
||||
{
|
||||
id: "doc-processor-agent",
|
||||
name: t("文档处理Agent", "Document Processing Agent"),
|
||||
description: t("智能文档解析、提取和转换", "Intelligent document parsing, extraction and conversion"),
|
||||
capabilities: [t("PDF解析", "PDF parsing"), t("文本提取", "Text extraction"), t("格式转换", "Format conversion")],
|
||||
status: "available",
|
||||
icon: "📄",
|
||||
},
|
||||
{
|
||||
id: "email-agent",
|
||||
name: t("邮件管理Agent", "Email Management Agent"),
|
||||
description: t("自动化邮件处理和智能回复", "Automated email processing and intelligent reply"),
|
||||
capabilities: [
|
||||
t("邮件分类", "Email classification"),
|
||||
t("自动回复", "Auto reply"),
|
||||
t("内容总结", "Content summary"),
|
||||
],
|
||||
status: "available",
|
||||
icon: "📧",
|
||||
},
|
||||
{
|
||||
id: "api-integration-agent",
|
||||
name: t("API集成Agent", "API Integration Agent"),
|
||||
description: t("连接和编排第三方API服务", "Connect and orchestrate third-party API services"),
|
||||
capabilities: [t("API调用", "API calls"), t("数据转换", "Data transformation"), t("错误处理", "Error handling")],
|
||||
status: "available",
|
||||
icon: "🔌",
|
||||
},
|
||||
{
|
||||
id: "database-agent",
|
||||
name: t("数据库操作Agent", "Database Operations Agent"),
|
||||
description: t("执行数据库查询和数据管理", "Execute database queries and data management"),
|
||||
capabilities: [t("SQL查询", "SQL queries"), t("数据迁移", "Data migration"), t("备份恢复", "Backup & restore") ],
|
||||
status: "available",
|
||||
icon: "🗄️",
|
||||
},
|
||||
]
|
||||
|
||||
const handleDeploy = (agent: any) => {
|
||||
setSelectedAgent(agent)
|
||||
setShowDeployDialog(true)
|
||||
}
|
||||
|
||||
const handleConfirmDeploy = async () => {
|
||||
if (!selectedAgent || !deployConfig.serviceGateway) {
|
||||
toast({
|
||||
title: t("错误", "Error"),
|
||||
description: t("请选择服务网关", "Please select service gateway"),
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await TaijiAPIClient.deployAgent({
|
||||
agentId: selectedAgent.id,
|
||||
instances: deployConfig.agentCount,
|
||||
model: deployConfig.model,
|
||||
gateway: deployConfig.serviceGateway as "MCP" | "A2A" | "API",
|
||||
})
|
||||
|
||||
if (result?.success) {
|
||||
toast({
|
||||
title: t("部署成功", "Deployment Successful"),
|
||||
description: t("Agent部署已启动", "Agent deployment started"),
|
||||
})
|
||||
setShowDeployDialog(false)
|
||||
loadAgents() // 重新加载数据
|
||||
} else {
|
||||
throw new Error(result?.message || "Deployment failed")
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: t("部署失败", "Deployment Failed"),
|
||||
description: error.message || t("无法部署Agent", "Failed to deploy agent"),
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t("代理工厂", "Agent Factory")}</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t("平台原生Agent部署和资源管理", "Platform native agent deployment and resource management")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("可用Agent", "Available Agents")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? (
|
||||
<span className="inline-block h-7 w-12 animate-pulse bg-muted rounded" />
|
||||
) : (
|
||||
platformAgents.length || defaultPlatformAgents.length
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("平台原生", "Platform native")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("已部署Agent", "Deployed Agents")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? (
|
||||
<span className="inline-block h-7 w-8 animate-pulse bg-muted rounded" />
|
||||
) : (
|
||||
deployedCount
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("运行中", "Running")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("总CPU使用", "Total CPU Usage")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? (
|
||||
<span className="inline-block h-7 w-8 animate-pulse bg-muted rounded" />
|
||||
) : (
|
||||
stats.cpu || 0
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("核心", "Cores")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("总内存使用", "Total Memory Usage")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? (
|
||||
<span className="inline-block h-7 w-16 animate-pulse bg-muted rounded" />
|
||||
) : (
|
||||
`${stats.memory || 0}GB`
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("已分配", "Allocated")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("平台原生Agent库", "Platform Native Agent Library")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("选择并部署预构建的Agent到您的资源", "Select and deploy pre-built agents to your resources")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="h-4 w-24 bg-muted rounded" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-20 bg-muted rounded" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{(platformAgents.length > 0 ? platformAgents : defaultPlatformAgents).map((agent) => (
|
||||
<Card key={agent.id} className="border-primary/20 hover:border-primary/40 transition-colors">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="text-4xl">{agent.icon}</div>
|
||||
<Badge className="bg-green-500/10 text-green-500">{agent.status}</Badge>
|
||||
</div>
|
||||
<CardTitle className="text-base">{agent.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{agent.description}</p>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">{t("核心能力", "Core Capabilities")}:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{agent.capabilities.map((cap: string) => (
|
||||
<Badge key={cap} variant="outline" className="text-xs">
|
||||
{cap}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full gap-2" onClick={() => handleDeploy(agent)}>
|
||||
<Zap className="h-4 w-4" />
|
||||
{t("部署Agent", "Deploy Agent")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 部署Agent对话框 */}
|
||||
<Dialog open={showDeployDialog} onOpenChange={setShowDeployDialog}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("部署", "Deploy")} {selectedAgent?.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("配置Agent的运行环境和实例数量", "Configure runtime environment and instance count for agent")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-muted p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-3xl">{selectedAgent?.icon}</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">{selectedAgent?.name}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">{selectedAgent?.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4 space-y-4">
|
||||
<h3 className="font-semibold flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4" />
|
||||
{t("资源配置", "Resource Configuration")}
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("Agent实例数", "Agent Instances")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={deployConfig.agentCount}
|
||||
onChange={(e) => setDeployConfig({ ...deployConfig, agentCount: Number.parseInt(e.target.value) })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("建议范围: 1-10", "Recommended: 1-10")}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("使用的模型", "Model to Use")}</Label>
|
||||
<Select
|
||||
value={deployConfig.model}
|
||||
onValueChange={(value) => setDeployConfig({ ...deployConfig, model: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="gpt-4o">GPT-4o</SelectItem>
|
||||
<SelectItem value="gpt-4o-mini">GPT-4o Mini</SelectItem>
|
||||
<SelectItem value="claude-3.5-sonnet">Claude 3.5 Sonnet</SelectItem>
|
||||
<SelectItem value="grok-4">Grok 4</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("服务网关", "Service Gateway")} *</Label>
|
||||
<Select
|
||||
value={deployConfig.serviceGateway}
|
||||
onValueChange={(value) => setDeployConfig({ ...deployConfig, serviceGateway: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("选择服务网关类型", "Select service gateway type")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mcp">MCP</SelectItem>
|
||||
<SelectItem value="a2a">A2A</SelectItem>
|
||||
<SelectItem value="api">API</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("选择Agent使用的服务网关协议", "Select the service gateway protocol for the agent")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-primary/5 border border-primary/20 p-4">
|
||||
<p className="font-medium mb-3 flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" />
|
||||
{t("部署摘要", "Deployment Summary")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
<span>
|
||||
{deployConfig.agentCount} {t("个实例", "instances")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{deployConfig.model}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
<span>
|
||||
2 {t("核/实例", "cores/instance")} ({t("固定", "Fixed")})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MemoryStick className="h-4 w-4 text-muted-foreground" />
|
||||
<span>
|
||||
4GB/{t("实例", "instance")} ({t("固定", "Fixed")})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{deployConfig.serviceGateway && (
|
||||
<div className="mt-2 text-sm">
|
||||
<span className="text-muted-foreground">{t("服务网关", "Service Gateway")}: </span>
|
||||
<span className="font-medium uppercase">{deployConfig.serviceGateway}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 pt-3 border-t border-primary/20">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("总资源", "Total resources")}: {deployConfig.agentCount * 2} {t("核", "cores")},{" "}
|
||||
{deployConfig.agentCount * 4}GB {t("内存", "memory")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowDeployDialog(false)}>
|
||||
{t("取消", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={!deployConfig.serviceGateway} onClick={handleConfirmDeploy}>
|
||||
{t("确认部署", "Confirm Deployment")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
"use client"
|
||||
|
||||
import { DashboardLayout } from "@/components/dashboard-layout"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Zap, Download, Filter, Calendar, Plus, Wallet } from "lucide-react"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, LineChart, Line } from "recharts"
|
||||
import { useLanguage } from "@/hooks/useLanguage"
|
||||
import { useState, useEffect } from "react"
|
||||
import { TaijiAPIClient } from "@/lib/api-client"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
export default function BillingPage() {
|
||||
const { t } = useLanguage()
|
||||
const { toast } = useToast()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showFilterDialog, setShowFilterDialog] = useState(false)
|
||||
const [showDateDialog, setShowDateDialog] = useState(false)
|
||||
const [showExportDialog, setShowExportDialog] = useState(false)
|
||||
const [showRechargeDialog, setShowRechargeDialog] = useState(false)
|
||||
const [rechargeAmount, setRechargeAmount] = useState("")
|
||||
const [balance, setBalance] = useState({ balance: 0, monthlySpent: 0, euBalance: 0 })
|
||||
const [euUsageData, setEuUsageData] = useState<Array<{ date: string; eu: number }>>([])
|
||||
const [costBreakdown, setCostBreakdown] = useState<Array<{ category: string; cost: number; eu: number }>>([])
|
||||
const [resourceUsage, setResourceUsage] = useState({
|
||||
cpu: { used: 0, total: 100 },
|
||||
memory: { used: 0, total: 200 },
|
||||
storage: { used: 0, total: 1000 },
|
||||
apiCalls: { used: 0, total: 100000 },
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadBillingData()
|
||||
}, [])
|
||||
|
||||
const loadBillingData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
// 并行加载数据
|
||||
const [balanceResult, historyResult, costResult] = await Promise.allSettled([
|
||||
TaijiAPIClient.getBillingBalance(),
|
||||
TaijiAPIClient.getBillingHistory({
|
||||
startTime: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
endTime: new Date().toISOString(),
|
||||
}),
|
||||
TaijiAPIClient.getBillingHistory({
|
||||
startTime: new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString(),
|
||||
endTime: new Date().toISOString(),
|
||||
}),
|
||||
])
|
||||
|
||||
// 处理余额
|
||||
if (balanceResult.status === "fulfilled" && balanceResult.value?.success) {
|
||||
const data = balanceResult.value.data
|
||||
setBalance({
|
||||
balance: data.balance || 0,
|
||||
monthlySpent: data.monthlySpent || 0,
|
||||
euBalance: data.euBalance || 0,
|
||||
})
|
||||
}
|
||||
|
||||
// 处理历史数据(用于图表)
|
||||
if (historyResult.status === "fulfilled" && historyResult.value?.success) {
|
||||
const records = historyResult.value.data?.records || []
|
||||
// 按日期聚合EU消耗
|
||||
const dailyEu: Record<string, number> = {}
|
||||
records.forEach((record: any) => {
|
||||
const date = new Date(record.timestamp).toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
||||
dailyEu[date] = (dailyEu[date] || 0) + (record.eu || 0)
|
||||
})
|
||||
setEuUsageData(
|
||||
Object.entries(dailyEu).map(([date, eu]) => ({ date, eu })).slice(-8),
|
||||
)
|
||||
}
|
||||
|
||||
// 处理费用明细
|
||||
if (costResult.status === "fulfilled" && costResult.value?.success) {
|
||||
const records = costResult.value.data?.records || []
|
||||
// 按类别聚合费用
|
||||
const categoryCosts: Record<string, { cost: number; eu: number }> = {}
|
||||
records.forEach((record: any) => {
|
||||
const category = record.agentType || "Other"
|
||||
if (!categoryCosts[category]) {
|
||||
categoryCosts[category] = { cost: 0, eu: 0 }
|
||||
}
|
||||
categoryCosts[category].cost += record.cost || 0
|
||||
categoryCosts[category].eu += record.eu || 0
|
||||
})
|
||||
setCostBreakdown(
|
||||
Object.entries(categoryCosts).map(([category, data]) => ({
|
||||
category,
|
||||
...data,
|
||||
})),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load billing data:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRecharge = async () => {
|
||||
if (!rechargeAmount || Number.parseFloat(rechargeAmount) <= 0) {
|
||||
toast({
|
||||
title: t("错误", "Error"),
|
||||
description: t("请输入有效的充值金额", "Please enter a valid amount"),
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await TaijiAPIClient.rechargeBalance(Number.parseFloat(rechargeAmount))
|
||||
if (result?.success) {
|
||||
toast({
|
||||
title: t("充值成功", "Recharge Successful"),
|
||||
description: t("余额已更新", "Balance updated"),
|
||||
})
|
||||
setShowRechargeDialog(false)
|
||||
setRechargeAmount("")
|
||||
loadBillingData()
|
||||
} else {
|
||||
throw new Error(result?.message || "Recharge failed")
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: t("充值失败", "Recharge Failed"),
|
||||
description: error.message || t("无法完成充值", "Failed to recharge"),
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t("计费与资源平面", "Billing & Resources Plane")}</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t("即用即付,按实际消费计费", "Pay-as-you-go, billing based on actual consumption")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="gap-2 bg-transparent" onClick={() => setShowDateDialog(true)}>
|
||||
<Calendar className="h-4 w-4" />
|
||||
{t("时间查询", "Date Query")}
|
||||
</Button>
|
||||
<Button variant="outline" className="gap-2 bg-transparent" onClick={() => setShowFilterDialog(true)}>
|
||||
<Filter className="h-4 w-4" />
|
||||
{t("筛选", "Filter")}
|
||||
</Button>
|
||||
<Button variant="outline" className="gap-2 bg-transparent" onClick={() => setShowExportDialog(true)}>
|
||||
<Download className="h-4 w-4" />
|
||||
{t("导出", "Export")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-gradient-to-r from-primary/10 to-primary/5 border-primary/20">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Wallet className="h-5 w-5" />
|
||||
{t("账户余额", "Account Balance")}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
{t("即用即付模式,按实际消费扣费", "Pay-as-you-go mode, deducted based on actual consumption")}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button className="gap-2" onClick={() => setShowRechargeDialog(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("充值", "Recharge")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-4xl font-bold">
|
||||
{loading ? (
|
||||
<span className="inline-block h-10 w-32 animate-pulse bg-muted rounded" />
|
||||
) : (
|
||||
`¥${balance.balance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{loading ? (
|
||||
<span className="inline-block h-4 w-40 animate-pulse bg-muted rounded" />
|
||||
) : (
|
||||
t("本月已消费", "This month consumed") + `: ¥${balance.monthlySpent.toFixed(2)}`
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("EU余额", "EU Balance")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? (
|
||||
<span className="inline-block h-7 w-16 animate-pulse bg-muted rounded" />
|
||||
) : (
|
||||
balance.euBalance.toLocaleString()
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("可用单位", "Available units")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("本月", "This Month")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? (
|
||||
<span className="inline-block h-7 w-16 animate-pulse bg-muted rounded" />
|
||||
) : (
|
||||
`¥${balance.monthlySpent.toFixed(2)}`
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{loading ? "" : `${Math.round(balance.monthlySpent * 100)} EU ${t("已消费", "consumed")}`}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("平均每日费用", "Avg Daily Cost")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">$34</div>
|
||||
<p className="text-xs text-green-500 mt-1">-5% {t("vs上月", "vs last month")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("预计", "Projected")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">$1,050</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("月底", "End of month")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("EU消费历史", "EU Consumption History")}</CardTitle>
|
||||
<CardDescription>{t("执行单位使用情况随时间变化", "Execution Units usage over time")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={euUsageData.length > 0 ? euUsageData : [{ date: "", eu: 0 }]}>
|
||||
<XAxis dataKey="date" stroke="oklch(0.45 0 0)" fontSize={12} tickLine={false} />
|
||||
<YAxis stroke="oklch(0.45 0 0)" fontSize={12} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "oklch(0.15 0 0)",
|
||||
border: "1px solid oklch(0.22 0 0)",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="eu"
|
||||
stroke="oklch(0.60 0.20 264)"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "oklch(0.60 0.20 264)", r: 4 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("费用明细", "Cost Breakdown")}</CardTitle>
|
||||
<CardDescription>{t("当月支出(按类别)", "Current month spending by category")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={costBreakdown.length > 0 ? costBreakdown : [{ category: "", cost: 0, eu: 0 }]}>
|
||||
<XAxis dataKey="category" stroke="oklch(0.45 0 0)" fontSize={11} tickLine={false} />
|
||||
<YAxis stroke="oklch(0.45 0 0)" fontSize={12} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "oklch(0.15 0 0)",
|
||||
border: "1px solid oklch(0.22 0 0)",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="cost" fill="oklch(0.65 0.16 220)" radius={[8, 8, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("资源使用", "Resource Usage")}</CardTitle>
|
||||
<CardDescription>{t("当前分配和限制", "Current allocation and limits")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[
|
||||
{
|
||||
name: "CPU",
|
||||
used: resourceUsage.cpu.used,
|
||||
total: resourceUsage.cpu.total,
|
||||
unit: t("核", "cores"),
|
||||
},
|
||||
{
|
||||
name: t("内存", "Memory"),
|
||||
used: resourceUsage.memory.used,
|
||||
total: resourceUsage.memory.total,
|
||||
unit: "GB",
|
||||
},
|
||||
{
|
||||
name: t("存储", "Storage"),
|
||||
used: resourceUsage.storage.used,
|
||||
total: resourceUsage.storage.total,
|
||||
unit: "GB",
|
||||
},
|
||||
{
|
||||
name: t("API调用", "API Calls"),
|
||||
used: resourceUsage.apiCalls.used,
|
||||
total: resourceUsage.apiCalls.total,
|
||||
unit: t("次", "calls"),
|
||||
},
|
||||
].map((resource) => (
|
||||
<div key={resource.name}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">{resource.name}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{resource.used} / {resource.total} {resource.unit}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={(resource.used / resource.total) * 100} className="h-2" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("EU定价详情", "EU Pricing Details")}</CardTitle>
|
||||
<CardDescription>{t("了解执行单位", "Understanding Execution Units")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{[
|
||||
{
|
||||
service: t("模型推理", "Model Inference"),
|
||||
rate: "0.025 EU/call",
|
||||
desc: t("LLM API调用", "LLM API calls"),
|
||||
},
|
||||
{
|
||||
service: t("API工具", "API Tools"),
|
||||
rate: "0.001 EU/call",
|
||||
desc: t("外部API使用", "External API usage"),
|
||||
},
|
||||
{
|
||||
service: t("VM计算", "VM Compute"),
|
||||
rate: "0.5 EU/hour",
|
||||
desc: t("Firecracker VM运行时", "Firecracker VM runtime"),
|
||||
},
|
||||
{
|
||||
service: t("数据存储", "Data Storage"),
|
||||
rate: "0.01 EU/GB/day",
|
||||
desc: t("持久存储", "Persistent storage"),
|
||||
},
|
||||
].map((pricing) => (
|
||||
<div key={pricing.service} className="rounded-lg border border-border bg-muted/30 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Zap className="h-4 w-4 text-primary" />
|
||||
<h3 className="font-semibold text-sm">{pricing.service}</h3>
|
||||
</div>
|
||||
<p className="text-lg font-mono font-bold mb-1">{pricing.rate}</p>
|
||||
<p className="text-xs text-muted-foreground">{pricing.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={showFilterDialog} onOpenChange={setShowFilterDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("筛选选项", "Filter Options")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label>{t("客户名称", "Customer Name")}</Label>
|
||||
<Input placeholder={t("输入客户名称", "Enter customer name")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("最小调用次数", "Min Calls")}</Label>
|
||||
<Input type="number" placeholder="0" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("最大调用次数", "Max Calls")}</Label>
|
||||
<Input type="number" placeholder="10000" />
|
||||
</div>
|
||||
<Button className="w-full">{t("应用筛选", "Apply Filter")}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showDateDialog} onOpenChange={setShowDateDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("时间范围", "Date & Time Range")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label>{t("开始时间 (年-月-日 时:分)", "Start Date & Time (YYYY-MM-DD HH:MM)")}</Label>
|
||||
<Input type="datetime-local" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("结束时间 (年-月-日 时:分)", "End Date & Time (YYYY-MM-DD HH:MM)")}</Label>
|
||||
<Input type="datetime-local" />
|
||||
</div>
|
||||
<Button className="w-full">{t("查询", "Query")}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showExportDialog} onOpenChange={setShowExportDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("导出格式", "Export Format")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<p className="text-sm text-muted-foreground">{t("选择导出文件格式", "Select export file format")}</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button variant="outline" className="h-20 flex-col gap-2 bg-transparent">
|
||||
<Download className="h-5 w-5" />
|
||||
<span>Excel (.xlsx)</span>
|
||||
</Button>
|
||||
<Button variant="outline" className="h-20 flex-col gap-2 bg-transparent">
|
||||
<Download className="h-5 w-5" />
|
||||
<span>CSV (.csv)</span>
|
||||
</Button>
|
||||
<Button variant="outline" className="h-20 flex-col gap-2 col-span-2 bg-transparent">
|
||||
<Download className="h-5 w-5" />
|
||||
<span>PDF (.pdf)</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showRechargeDialog} onOpenChange={setShowRechargeDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("账户充值", "Account Recharge")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label>{t("充值金额 (USD)", "Recharge Amount (USD)")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="100"
|
||||
value={rechargeAmount}
|
||||
onChange={(e) => setRechargeAmount(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("最低充值金额: $10", "Minimum recharge: $10")}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[50, 100, 500, 1000].map((amount) => (
|
||||
<Button
|
||||
key={amount}
|
||||
variant="outline"
|
||||
onClick={() => setRechargeAmount(amount.toString())}
|
||||
className="bg-transparent"
|
||||
>
|
||||
${amount}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted p-3 space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{t("充值金额", "Recharge Amount")}</span>
|
||||
<span className="font-medium">${rechargeAmount || "0.00"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{t("当前余额", "Current Balance")}</span>
|
||||
<span className="font-medium">$2,487.50</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm font-bold pt-2 border-t">
|
||||
<span>{t("充值后余额", "Balance After Recharge")}</span>
|
||||
<span>${(2487.5 + Number.parseFloat(rechargeAmount || "0")).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowRechargeDialog(false)}>
|
||||
{t("取消", "Cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleRecharge}>{t("确认充值", "Confirm Recharge")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,151 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Building2, Globe, Eye, EyeOff } from "lucide-react"
|
||||
import { useLanguage } from "@/contexts/language-context"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
|
||||
export default function ChannelLogin() {
|
||||
const router = useRouter()
|
||||
const { language, setLanguage } = useLanguage()
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
title: "Channel Partner Portal",
|
||||
subtitle: "Manage your tenant portfolio",
|
||||
email: "Email Address",
|
||||
password: "Password",
|
||||
login: "Sign In",
|
||||
loggingIn: "Signing In...",
|
||||
forgotPassword: "Forgot password?",
|
||||
contactSupport: "Contact Support",
|
||||
emailPlaceholder: "channel@company.com",
|
||||
passwordPlaceholder: "Enter your password",
|
||||
},
|
||||
zh: {
|
||||
title: "渠道合作伙伴平台",
|
||||
subtitle: "管理您的租户组合",
|
||||
email: "邮箱地址",
|
||||
password: "密码",
|
||||
login: "登录",
|
||||
loggingIn: "登录中...",
|
||||
forgotPassword: "忘记密码?",
|
||||
contactSupport: "联系支持",
|
||||
emailPlaceholder: "channel@company.com",
|
||||
passwordPlaceholder: "请输入密码",
|
||||
},
|
||||
}
|
||||
|
||||
const text = translations[language]
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
localStorage.setItem("channel_token", "mock_channel_token")
|
||||
router.push("/channel/dashboard")
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4 relative overflow-hidden">
|
||||
{/* Background pattern */}
|
||||
<div className="absolute inset-0 bg-grid-white/[0.02] bg-[size:50px_50px]" />
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-transparent" />
|
||||
|
||||
{/* Language selector */}
|
||||
<div className="absolute top-6 right-6 z-10">
|
||||
<Select value={language} onValueChange={(value: "en" | "zh") => setLanguage(value)}>
|
||||
<SelectTrigger className="w-[140px] bg-card border-border">
|
||||
<Globe className="mr-2 h-4 w-4" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
<SelectItem value="zh">中文</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Card className="w-full max-w-md bg-card border-border shadow-2xl relative z-10">
|
||||
<div className="p-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-blue-500 to-blue-600 mb-4">
|
||||
<Building2 className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground mb-2">{text.title}</h1>
|
||||
<p className="text-sm text-muted-foreground">{text.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{/* Login form */}
|
||||
<form onSubmit={handleLogin} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium text-foreground">
|
||||
{text.email}
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={text.emailPlaceholder}
|
||||
className="bg-background border-border"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium text-foreground">
|
||||
{text.password}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={text.passwordPlaceholder}
|
||||
className="bg-background border-border pr-10"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<button type="button" className="text-primary hover:underline">
|
||||
{text.forgotPassword}
|
||||
</button>
|
||||
<button type="button" className="text-muted-foreground hover:text-foreground">
|
||||
{text.contactSupport}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isLoading} className="w-full bg-primary text-primary-foreground h-11">
|
||||
{isLoading ? text.loggingIn : text.login}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useLanguage } from "@/hooks/useLanguage"
|
||||
import { TaijiAPIClient } from "@/lib/api-client"
|
||||
import { DashboardLayout } from "@/components/dashboard-layout"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Plus, Search, ExternalLink, Trash2, FileJson, Cloud, Cpu, Code2, Boxes, Workflow } from "lucide-react"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
|
||||
export default function DataToolsPage() {
|
||||
const { t } = useLanguage()
|
||||
const [tools, setTools] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [stats, setStats] = useState({ totalApis: 0, generatedTools: 0, activePods: 0 })
|
||||
|
||||
const [showNewToolDialog, setShowNewToolDialog] = useState(false)
|
||||
const [showTemplateDialog, setShowTemplateDialog] = useState(false)
|
||||
const [templateType, setTemplateType] = useState<"json" | "storage">("json")
|
||||
const [podConfig, setPodConfig] = useState({
|
||||
agentCount: 1,
|
||||
cpuCores: 1,
|
||||
memoryGB: 2,
|
||||
maxAgents: 5,
|
||||
model: "gpt-4o-mini",
|
||||
})
|
||||
const [selectedFramework, setSelectedFramework] = useState<string>("")
|
||||
const [selectedServiceGateway, setSelectedServiceGateway] = useState<string>("")
|
||||
|
||||
useEffect(() => {
|
||||
loadTools()
|
||||
loadStats()
|
||||
}, [])
|
||||
|
||||
const loadTools = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await TaijiAPIClient.getTools()
|
||||
if (Array.isArray(result)) {
|
||||
setTools(result)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load tools:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const result = await TaijiAPIClient.getStats()
|
||||
if (result) {
|
||||
setStats({
|
||||
totalApis: result.total_apis || 0,
|
||||
generatedTools: result.generated_tools || 0,
|
||||
activePods: result.active_pods || 0,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load stats:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t("数据与工具", "Data & Tools")}</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t("API市场和工具生成平台", "API marketplace and tool generation platform")}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowNewToolDialog(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("生成新工具", "Generate New Tool")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("总API数", "Total APIs")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? (
|
||||
<span className="inline-block h-7 w-12 animate-pulse bg-muted rounded" />
|
||||
) : (
|
||||
stats.totalApis
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{loading ? "" : `${stats.totalApis - stats.generatedTools} ${t("个已处理", "processed")}`}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("生成的工具", "Generated Tools")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? (
|
||||
<span className="inline-block h-7 w-8 animate-pulse bg-muted rounded" />
|
||||
) : (
|
||||
stats.generatedTools
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("可以使用", "Ready to use")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("活跃Pod数", "Active Pods")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? (
|
||||
<span className="inline-block h-7 w-8 animate-pulse bg-muted rounded" />
|
||||
) : (
|
||||
stats.activePods
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("运行中", "Running")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="tools" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="tools">{t("可用工具", "Available Tools")}</TabsTrigger>
|
||||
<TabsTrigger value="templates">{t("数据模板", "Data Templates")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="tools" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{t("工具注册表", "Tool Registry")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("管理和监控生成的API工具", "Manage and monitor generated API tools")}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder={t("搜索工具...", "Search tools...")} className="w-64" />
|
||||
<Button variant="outline" size="icon">
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("工具名称", "Tool Name")}</TableHead>
|
||||
<TableHead>{t("类别", "Category")}</TableHead>
|
||||
<TableHead>{t("方法", "Method")}</TableHead>
|
||||
<TableHead>{t("端点", "Endpoint")}</TableHead>
|
||||
<TableHead>{t("状态", "Status")}</TableHead>
|
||||
<TableHead>{t("创建时间", "Created")}</TableHead>
|
||||
<TableHead className="text-right">{t("操作", "Actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8">
|
||||
<span className="text-muted-foreground">{t("加载中...", "Loading...")}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : tools.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8">
|
||||
<span className="text-muted-foreground">{t("暂无工具", "No tools available")}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
tools.map((tool) => (
|
||||
<TableRow key={tool.name}>
|
||||
<TableCell className="font-medium">{tool.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{tool.category}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{tool.method}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{tool.url}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-green-500/10 text-green-500">{tool.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{tool.created}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="templates" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{t("数据模板管理", "Data Template Management")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("配置JSON接口和云存储数据源", "Configure JSON interfaces and cloud storage data sources")}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={() => setShowTemplateDialog(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("创建数据模板", "Create Data Template")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card className="border-primary/20 bg-primary/5">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileJson className="h-5 w-5 text-primary" />
|
||||
<CardTitle className="text-base">{t("JSON API 模板", "JSON API Template")}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t(
|
||||
"通过提供数据接口URL和查询参数来配置JSON API访问",
|
||||
"Configure JSON API access by providing interface URL and query parameters",
|
||||
)}
|
||||
</p>
|
||||
<Badge variant="secondary">{t("3个已配置", "3 configured")}</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-blue-500/20 bg-blue-500/5">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cloud className="h-5 w-5 text-blue-500" />
|
||||
<CardTitle className="text-base">
|
||||
{t("云存储与数据库模板", "Cloud Storage & Database Template")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t(
|
||||
"支持Azure、Google、AWS存储服务和各种原生数据库",
|
||||
"Support Azure, Google, AWS storage services and various native databases",
|
||||
)}
|
||||
</p>
|
||||
<Badge variant="secondary">{t("5个已配置", "5 configured")}</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* 生成新工具对话框 - 包含Pod配置 */}
|
||||
<Dialog open={showNewToolDialog} onOpenChange={setShowNewToolDialog}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("生成新工具并部署Pod", "Generate New Tool and Deploy Pod")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"根据Agent框架注册工具并配置Pod资源",
|
||||
"Register tool based on agent framework and configure pod resources",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("Agent框架模板", "Agent Framework Template")}</Label>
|
||||
<Select value={selectedFramework} onValueChange={setSelectedFramework}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("选择框架模板", "Select framework template")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mcp">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code2 className="h-4 w-4" />
|
||||
MCP - Model Context Protocol
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="a2a">
|
||||
<div className="flex items-center gap-2">
|
||||
<Boxes className="h-4 w-4" />
|
||||
A2A - Agent-to-Agent Protocol
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="api">
|
||||
<div className="flex items-center gap-2">
|
||||
<Workflow className="h-4 w-4" />
|
||||
API - Standard REST API
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("根据选择的框架注册相应的工具", "Register tools according to selected framework")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("工具名称", "Tool Name")}</Label>
|
||||
<Input placeholder={t("输入工具名称", "Enter tool name")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("API端点", "API Endpoint")}</Label>
|
||||
<Input placeholder="https://api.example.com/endpoint" />
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4 space-y-4">
|
||||
<h3 className="font-semibold flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4" />
|
||||
{t("Pod资源配置", "Pod Resource Configuration")}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("服务网关", "Service Gateway")}</Label>
|
||||
<Select value={selectedServiceGateway} onValueChange={setSelectedServiceGateway}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("选择服务网关", "Select service gateway")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mcp-gateway">MCP Gateway</SelectItem>
|
||||
<SelectItem value="a2a-gateway">A2A Gateway</SelectItem>
|
||||
<SelectItem value="api-gateway">API Gateway</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("选择Pod使用的服务网关", "Select service gateway for the pod")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("Agent个数", "Agent Count")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={podConfig.agentCount}
|
||||
onChange={(e) => setPodConfig({ ...podConfig, agentCount: Number.parseInt(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("CPU核数", "CPU Cores")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="16"
|
||||
value={podConfig.cpuCores}
|
||||
onChange={(e) => setPodConfig({ ...podConfig, cpuCores: Number.parseInt(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("内存大小 (GB)", "Memory Size (GB)")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="64"
|
||||
value={podConfig.memoryGB}
|
||||
onChange={(e) => setPodConfig({ ...podConfig, memoryGB: Number.parseInt(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("可扩展Agent数量", "Scalable Agent Count")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={podConfig.maxAgents}
|
||||
onChange={(e) => setPodConfig({ ...podConfig, maxAgents: Number.parseInt(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("使用的模型", "Model to Use")}</Label>
|
||||
<Select value={podConfig.model} onValueChange={(value) => setPodConfig({ ...podConfig, model: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="gpt-4o">GPT-4o</SelectItem>
|
||||
<SelectItem value="gpt-4o-mini">GPT-4o Mini</SelectItem>
|
||||
<SelectItem value="claude-3.5-sonnet">Claude 3.5 Sonnet</SelectItem>
|
||||
<SelectItem value="grok-4">Grok 4</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted p-4 text-sm">
|
||||
<p className="font-medium mb-2">{t("配置摘要", "Configuration Summary")}</p>
|
||||
<div className="grid grid-cols-2 gap-2 text-muted-foreground">
|
||||
<div className="col-span-2">
|
||||
{t("框架", "Framework")}: {selectedFramework || t("未选择", "Not selected")}
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
{t("网关", "Gateway")}: {selectedServiceGateway || t("未选择", "Not selected")}
|
||||
</div>
|
||||
<div>
|
||||
{t("Agent数量", "Agent Count")}: {podConfig.agentCount}
|
||||
</div>
|
||||
<div>
|
||||
{t("CPU", "CPU")}: {podConfig.cpuCores} {t("核", "cores")}
|
||||
</div>
|
||||
<div>
|
||||
{t("内存", "Memory")}: {podConfig.memoryGB}GB
|
||||
</div>
|
||||
<div>
|
||||
{t("最大扩展", "Max Scale")}: {podConfig.maxAgents}
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
{t("模型", "Model")}: {podConfig.model}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowNewToolDialog(false)}>
|
||||
{t("取消", "Cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => setShowNewToolDialog(false)}>{t("部署工具和Pod", "Deploy Tool & Pod")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 数据模板对话框 */}
|
||||
<Dialog open={showTemplateDialog} onOpenChange={setShowTemplateDialog}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("创建数据模板", "Create Data Template")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("选择模板类型并填写配置信息", "Select template type and fill in configuration information")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("模板类型", "Template Type")}</Label>
|
||||
<Select value={templateType} onValueChange={(value: "json" | "storage") => setTemplateType(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="json">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileJson className="h-4 w-4" />
|
||||
{t("JSON API 模板", "JSON API Template")}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="storage">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cloud className="h-4 w-4" />
|
||||
{t("云存储与数据库模板", "Cloud Storage & Database Template")}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("模板名称", "Template Name")}</Label>
|
||||
<Input placeholder={t("输入模板名称", "Enter template name")} />
|
||||
</div>
|
||||
|
||||
{templateType === "json" ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("数据接口URL", "Data Interface URL")}</Label>
|
||||
<Input placeholder="https://api.example.com/data" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("查询参数", "Query Parameters")}</Label>
|
||||
<textarea
|
||||
className="w-full h-24 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
placeholder={t(
|
||||
"输入查询参数,例如: page=1&limit=10",
|
||||
"Enter query parameters, e.g., page=1&limit=10",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("存储服务", "Storage Service")}</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("选择服务", "Select service")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="azure-blob">Azure Blob Storage</SelectItem>
|
||||
<SelectItem value="google-cloud">Google Cloud Storage</SelectItem>
|
||||
<SelectItem value="aws-s3">AWS S3</SelectItem>
|
||||
<SelectItem value="postgresql">PostgreSQL</SelectItem>
|
||||
<SelectItem value="mysql">MySQL</SelectItem>
|
||||
<SelectItem value="mongodb">MongoDB</SelectItem>
|
||||
<SelectItem value="snowflake">Snowflake</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("连接字符串", "Connection String")}</Label>
|
||||
<Input placeholder={t("输入连接字符串", "Enter connection string")} type="password" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowTemplateDialog(false)}>
|
||||
{t("取消", "Cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => setShowTemplateDialog(false)}>{t("创建模板", "Create Template")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: oklch(0.98 0 0);
|
||||
--foreground: oklch(0.15 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.15 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.15 0 0);
|
||||
--primary: oklch(0.45 0.19 264);
|
||||
--primary-foreground: oklch(0.98 0 0);
|
||||
--secondary: oklch(0.55 0.15 220);
|
||||
--secondary-foreground: oklch(0.98 0 0);
|
||||
--muted: oklch(0.96 0 0);
|
||||
--muted-foreground: oklch(0.5 0 0);
|
||||
--accent: oklch(0.45 0.19 264);
|
||||
--accent-foreground: oklch(0.98 0 0);
|
||||
--destructive: oklch(0.55 0.22 25);
|
||||
--destructive-foreground: oklch(0.98 0 0);
|
||||
--border: oklch(0.9 0 0);
|
||||
--input: oklch(0.9 0 0);
|
||||
--ring: oklch(0.45 0.19 264);
|
||||
--chart-1: oklch(0.55 0.2 264);
|
||||
--chart-2: oklch(0.6 0.15 220);
|
||||
--chart-3: oklch(0.5 0.18 300);
|
||||
--chart-4: oklch(0.65 0.18 160);
|
||||
--chart-5: oklch(0.58 0.16 50);
|
||||
--radius: 0.5rem;
|
||||
--sidebar: oklch(0.98 0 0);
|
||||
--sidebar-foreground: oklch(0.15 0 0);
|
||||
--sidebar-primary: oklch(0.45 0.19 264);
|
||||
--sidebar-primary-foreground: oklch(0.98 0 0);
|
||||
--sidebar-accent: oklch(0.95 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.15 0 0);
|
||||
--sidebar-border: oklch(0.9 0 0);
|
||||
--sidebar-ring: oklch(0.45 0.19 264);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.12 0 0);
|
||||
--foreground: oklch(0.95 0 0);
|
||||
--card: oklch(0.15 0 0);
|
||||
--card-foreground: oklch(0.95 0 0);
|
||||
--popover: oklch(0.15 0 0);
|
||||
--popover-foreground: oklch(0.95 0 0);
|
||||
--primary: oklch(0.6 0.2 264);
|
||||
--primary-foreground: oklch(0.98 0 0);
|
||||
--secondary: oklch(0.65 0.16 220);
|
||||
--secondary-foreground: oklch(0.98 0 0);
|
||||
--muted: oklch(0.2 0 0);
|
||||
--muted-foreground: oklch(0.65 0 0);
|
||||
--accent: oklch(0.6 0.2 264);
|
||||
--accent-foreground: oklch(0.98 0 0);
|
||||
--destructive: oklch(0.5 0.2 25);
|
||||
--destructive-foreground: oklch(0.98 0 0);
|
||||
--border: oklch(0.22 0 0);
|
||||
--input: oklch(0.22 0 0);
|
||||
--ring: oklch(0.6 0.2 264);
|
||||
--chart-1: oklch(0.6 0.2 264);
|
||||
--chart-2: oklch(0.65 0.16 220);
|
||||
--chart-3: oklch(0.55 0.18 300);
|
||||
--chart-4: oklch(0.7 0.18 160);
|
||||
--chart-5: oklch(0.63 0.16 50);
|
||||
--sidebar: oklch(0.14 0 0);
|
||||
--sidebar-foreground: oklch(0.95 0 0);
|
||||
--sidebar-primary: oklch(0.6 0.2 264);
|
||||
--sidebar-primary-foreground: oklch(0.98 0 0);
|
||||
--sidebar-accent: oklch(0.2 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.95 0 0);
|
||||
--sidebar-border: oklch(0.22 0 0);
|
||||
--sidebar-ring: oklch(0.6 0.2 264);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: "Geist", "Geist Fallback";
|
||||
--font-mono: "Geist Mono", "Geist Mono Fallback";
|
||||
--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-destructive-foreground: var(--destructive-foreground);
|
||||
--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);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--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);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type React from "react"
|
||||
import type { Metadata } from "next"
|
||||
import { Geist, Geist_Mono } from "next/font/google"
|
||||
import { Analytics } from "@vercel/analytics/next"
|
||||
import { LanguageProvider } from "@/contexts/language-context"
|
||||
import { AuthGuard } from "@/components/auth-guard"
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
import "./globals.css"
|
||||
|
||||
const _geist = Geist({ subsets: ["latin"] })
|
||||
const _geistMono = Geist_Mono({ subsets: ["latin"] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Taiji AI Platform - Agent Empowerment",
|
||||
description: "Enterprise-grade AI Agent orchestration and management platform",
|
||||
generator: "v0.app",
|
||||
icons: {
|
||||
icon: [
|
||||
{
|
||||
url: "/icon-light-32x32.png",
|
||||
media: "(prefers-color-scheme: light)",
|
||||
},
|
||||
{
|
||||
url: "/icon-dark-32x32.png",
|
||||
media: "(prefers-color-scheme: dark)",
|
||||
},
|
||||
{
|
||||
url: "/icon.svg",
|
||||
type: "image/svg+xml",
|
||||
},
|
||||
],
|
||||
apple: "/apple-icon.png",
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body className={`font-sans antialiased`}>
|
||||
<LanguageProvider>
|
||||
<AuthGuard>{children}</AuthGuard>
|
||||
</LanguageProvider>
|
||||
<Toaster />
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { useLanguage } from "@/contexts/language-context"
|
||||
import { Eye, EyeOff, Globe } from "lucide-react"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { TaijiAPIClient } from "@/lib/api-client"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
|
||||
export default function LoginPage() {
|
||||
const { language, setLanguage, t } = useLanguage()
|
||||
const { toast } = useToast()
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
// 默认使用 user 角色登录,可以根据需要调整
|
||||
const result = await TaijiAPIClient.login(email, password, "user")
|
||||
|
||||
if (result && result.success) {
|
||||
toast({
|
||||
title: t("登录成功", "Login successful"),
|
||||
description: t("欢迎回来", "Welcome back"),
|
||||
})
|
||||
// Redirect to dashboard on success
|
||||
window.location.href = "/"
|
||||
} else {
|
||||
toast({
|
||||
title: t("登录失败", "Login failed"),
|
||||
description: (result as any)?.message || t("请检查您的邮箱和密码", "Please check your email and password"),
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Login error:", error)
|
||||
let errorMessage = error.message || t("网络错误,请稍后重试", "Network error, please try again")
|
||||
|
||||
// 检查是否是连接错误
|
||||
if (errorMessage.includes("无法连接到后端服务") || errorMessage.includes("Failed to fetch")) {
|
||||
errorMessage = t(
|
||||
"无法连接到后端服务,请确保后端服务正在运行",
|
||||
"Cannot connect to backend service, please ensure the backend is running"
|
||||
)
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t("登录失败", "Login failed"),
|
||||
description: errorMessage,
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4 relative">
|
||||
{/* Language Switcher */}
|
||||
<div className="absolute top-6 right-6">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2 bg-transparent">
|
||||
<Globe className="h-4 w-4" />
|
||||
{language === "zh" ? "中文" : "English"}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setLanguage("zh")}>中文</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setLanguage("en")}>English</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo and Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary/10 mb-4">
|
||||
<div className="w-8 h-8 rounded-full bg-primary" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">{t("Taiji AI 平台", "Taiji AI Platform")}</h1>
|
||||
<p className="text-muted-foreground">{t("企业级 AI Agent 赋能平台", "Enterprise AI Agent Empowerment")}</p>
|
||||
</div>
|
||||
|
||||
{/* Login Card */}
|
||||
<Card className="p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">{t("邮箱地址", "Email Address")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder={t("请输入邮箱", "Enter your email")}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">{t("密码", "Password")}</Label>
|
||||
<Button type="button" variant="link" className="px-0 text-sm text-primary hover:text-primary/80">
|
||||
{t("忘记密码?", "Forgot password?")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={t("请输入密码", "Enter your password")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="h-11 pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full h-11" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
{t("登录中...", "Signing in...")}
|
||||
</span>
|
||||
) : (
|
||||
t("登录", "Sign In")
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-muted-foreground">
|
||||
{t("如需账号,请联系管理员", "Please contact administrator for account access")}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 text-center text-xs text-muted-foreground">
|
||||
{t("© 2025 Taiji AI Platform. 保留所有权利。", "© 2025 Taiji AI Platform. All rights reserved.")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
"use client"
|
||||
import { DashboardLayout } from "@/components/dashboard-layout"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { GitBranch, Activity, Code2, Boxes, Workflow, Plus, Upload } from "lucide-react"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { useLanguage } from "@/hooks/useLanguage"
|
||||
import { TaijiAPIClient } from "@/lib/api-client"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { useState, useEffect } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
export default function ModelGatewayPage() {
|
||||
const { t } = useLanguage()
|
||||
const { toast } = useToast()
|
||||
const [selectedGateway, setSelectedGateway] = useState<string>("")
|
||||
const [showGatewayDialog, setShowGatewayDialog] = useState(false)
|
||||
const [showApiDialog, setShowApiDialog] = useState(false)
|
||||
const [apiUploadMethod, setApiUploadMethod] = useState<"file" | "url">("file")
|
||||
const [jsonFile, setJsonFile] = useState<File | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [frameworks, setFrameworks] = useState<any[]>([])
|
||||
const [providers, setProviders] = useState<any[]>([])
|
||||
const [gatewayAPIs, setGatewayAPIs] = useState<any[]>([])
|
||||
const [monitoring, setMonitoring] = useState<any>(null)
|
||||
const [stats, setStats] = useState({ availableGateways: 0, endpoints: 0, avgLatency: 0, requestsToday: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
loadGatewayData()
|
||||
}, [])
|
||||
|
||||
const loadGatewayData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
// 并行加载数据
|
||||
const [apisResult, monitoringResult, providersResult] = await Promise.allSettled([
|
||||
TaijiAPIClient.getGatewayAPIs(),
|
||||
TaijiAPIClient.getGatewayMonitoring(),
|
||||
TaijiAPIClient.getModelProviders(),
|
||||
])
|
||||
|
||||
// 加载网关API列表
|
||||
if (apisResult.status === "fulfilled" && apisResult.value?.success) {
|
||||
setGatewayAPIs(apisResult.value.data?.apis || [])
|
||||
}
|
||||
|
||||
// 加载监控数据
|
||||
if (monitoringResult.status === "fulfilled" && monitoringResult.value?.success) {
|
||||
const data = monitoringResult.value.data
|
||||
setMonitoring(data)
|
||||
setStats({
|
||||
availableGateways: 3, // 默认值
|
||||
endpoints: gatewayAPIs.length,
|
||||
avgLatency: data.averageLatency || 0,
|
||||
requestsToday: data.requestsPerMinute ? data.requestsPerMinute * 60 * 24 : 0,
|
||||
})
|
||||
}
|
||||
|
||||
// 加载模型提供商
|
||||
if (providersResult && providersResult.status === "fulfilled" && providersResult.value?.success) {
|
||||
const providersData = providersResult.value.data?.providers || []
|
||||
setProviders(
|
||||
providersData.map((p: any) => ({
|
||||
name: p.name,
|
||||
requests: p.requests || 0,
|
||||
latency: p.latency || 0,
|
||||
status: p.status || "healthy",
|
||||
})),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load gateway data:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const defaultFrameworks = [
|
||||
{
|
||||
name: "MCP",
|
||||
fullName: "Model Context Protocol",
|
||||
icon: Code2,
|
||||
endpoints: 8,
|
||||
usage: 42,
|
||||
status: "active",
|
||||
description: "适用于需要上下文理解的复杂对话场景",
|
||||
},
|
||||
{
|
||||
name: "A2A",
|
||||
fullName: "Agent-to-Agent Protocol",
|
||||
icon: Boxes,
|
||||
endpoints: 5,
|
||||
usage: 28,
|
||||
status: "active",
|
||||
description: "适用于多Agent协同工作的场景",
|
||||
},
|
||||
{
|
||||
name: "API",
|
||||
fullName: "Standard API Gateway",
|
||||
icon: Workflow,
|
||||
endpoints: 6,
|
||||
usage: 30,
|
||||
status: "active",
|
||||
description: "标准REST API,适用于简单的请求响应场景",
|
||||
},
|
||||
]
|
||||
|
||||
const frameworksToUse = frameworks.length > 0 ? frameworks : defaultFrameworks
|
||||
const providersToUse = providers.length > 0 ? providers : []
|
||||
|
||||
const handleSelectGateway = async (gatewayType: string) => {
|
||||
try {
|
||||
const result = await TaijiAPIClient.selectGateway(gatewayType as "MCP" | "A2A" | "API")
|
||||
if (result?.success) {
|
||||
setSelectedGateway(gatewayType)
|
||||
toast({
|
||||
title: t("选择成功", "Success"),
|
||||
description: t("服务网关已选择", "Service gateway selected"),
|
||||
})
|
||||
setShowGatewayDialog(false)
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: t("选择失败", "Failed"),
|
||||
description: error.message || t("无法选择服务网关", "Failed to select gateway"),
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateAPI = async (apiData: { name: string; method: "json" | "url"; content: string }) => {
|
||||
try {
|
||||
const result = await TaijiAPIClient.createGatewayAPI(apiData.name, apiData.method, apiData.content)
|
||||
if (result?.success) {
|
||||
toast({
|
||||
title: t("创建成功", "Success"),
|
||||
description: t("API已创建", "API created"),
|
||||
})
|
||||
setShowApiDialog(false)
|
||||
loadGatewayData()
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: t("创建失败", "Failed"),
|
||||
description: error.message || t("无法创建API", "Failed to create API"),
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t("服务网关", "Service Gateway")}</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t("选择服务方式并管理API路由", "Select service type and manage API routing")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setShowApiDialog(true)} variant="outline" className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("创建API", "Create API")}
|
||||
</Button>
|
||||
<Dialog open={showGatewayDialog} onOpenChange={setShowGatewayDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("选择服务网关", "Select Service Gateway")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("选择服务网关类型", "Select Service Gateway Type")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("选择适合您业务需求的网关服务方式", "Choose the gateway service type that fits your needs")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<RadioGroup value={selectedGateway} onValueChange={setSelectedGateway}>
|
||||
{frameworksToUse.map((framework) => {
|
||||
const Icon = framework.icon
|
||||
return (
|
||||
<div
|
||||
key={framework.name}
|
||||
className={`flex items-start space-x-3 rounded-lg border p-4 cursor-pointer transition-colors ${
|
||||
selectedGateway === framework.name
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:bg-accent"
|
||||
}`}
|
||||
onClick={() => handleSelectGateway(framework.name)}
|
||||
>
|
||||
<RadioGroupItem value={framework.name} id={framework.name} className="mt-1" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label htmlFor={framework.name} className="flex items-center gap-2 cursor-pointer">
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="font-semibold">{framework.name}</span>
|
||||
<span className="text-xs text-muted-foreground">- {framework.fullName}</span>
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">{framework.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setShowGatewayDialog(false)}>
|
||||
{t("取消", "Cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => handleSelectGateway(selectedGateway)} disabled={!selectedGateway}>
|
||||
{t("确认选择", "Confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("可用网关", "Available Gateways")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? (
|
||||
<span className="inline-block h-7 w-4 animate-pulse bg-muted rounded" />
|
||||
) : (
|
||||
stats.availableGateways
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-green-500 mt-1">{t("全部运行", "All operational")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("服务端点", "Service Endpoints")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? (
|
||||
<span className="inline-block h-7 w-8 animate-pulse bg-muted rounded" />
|
||||
) : (
|
||||
stats.endpoints
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("可用接口", "Available interfaces")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("平均延迟", "Avg Latency")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? (
|
||||
<span className="inline-block h-7 w-12 animate-pulse bg-muted rounded" />
|
||||
) : (
|
||||
`${stats.avgLatency}ms`
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("过去24小时", "Last 24 hours")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("今日调用", "Requests Today")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? (
|
||||
<span className="inline-block h-7 w-16 animate-pulse bg-muted rounded" />
|
||||
) : (
|
||||
stats.requestsToday > 1000
|
||||
? `${(stats.requestsToday / 1000).toFixed(1)}K`
|
||||
: stats.requestsToday
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("总请求数", "Total requests")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="frameworks" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="frameworks">{t("网关类型", "Gateway Types")}</TabsTrigger>
|
||||
<TabsTrigger value="monitoring">{t("监控", "Monitoring")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="frameworks" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("服务网关选择", "Service Gateway Selection")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("选择适合您需求的服务网关类型", "Choose the service gateway type that fits your needs")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{frameworksToUse.map((framework) => {
|
||||
const Icon = framework.icon
|
||||
return (
|
||||
<div
|
||||
key={framework.name}
|
||||
className="flex items-center justify-between rounded-lg border border-border bg-card p-4"
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-lg">{framework.name}</h3>
|
||||
<Badge className="bg-green-500/10 text-green-500 text-xs">{framework.status}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-1">{framework.fullName}</p>
|
||||
<p className="text-xs text-muted-foreground">{framework.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold">{framework.endpoints}</div>
|
||||
<p className="text-xs text-muted-foreground">{t("端点", "Endpoints")}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold">{framework.usage}%</div>
|
||||
<p className="text-xs text-muted-foreground">{t("使用率", "Usage")}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
{t("查看文档", "View Docs")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("网关使用分布", "Gateway Usage Distribution")}</CardTitle>
|
||||
<CardDescription>{t("各网关的请求占比", "Request distribution by gateway")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{frameworksToUse.map((framework) => (
|
||||
<div key={framework.name}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">{framework.name}</span>
|
||||
<span className="text-sm text-muted-foreground">{framework.usage}%</span>
|
||||
</div>
|
||||
<Progress value={framework.usage} className="h-2" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="monitoring" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("模型提供商监控", "Model Provider Monitoring")}</CardTitle>
|
||||
<CardDescription>{t("实时性能和健康状态", "Real-time performance and health status")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{providersToUse.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("暂无数据", "No data available")}
|
||||
</div>
|
||||
) : (
|
||||
providersToUse.map((provider) => (
|
||||
<div
|
||||
key={provider.name}
|
||||
className="flex items-center justify-between rounded-lg border border-border bg-card p-4"
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<GitBranch className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold">{provider.name}</h3>
|
||||
<Badge className="bg-green-500/10 text-green-500 text-xs">{provider.status}</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{provider.requests.toLocaleString()} {t("请求", "requests")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-mono">{provider.latency}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("请求负载分布", "Request Load Distribution")}</CardTitle>
|
||||
<CardDescription>{t("各提供商的流量占比", "Traffic distribution by provider")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{providersToUse.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("暂无数据", "No data available")}
|
||||
</div>
|
||||
) : (
|
||||
providersToUse.map((provider) => {
|
||||
const total = providersToUse.reduce((sum, p) => sum + p.requests, 0)
|
||||
const percentage = total > 0 ? Math.round((provider.requests / total) * 100) : 0
|
||||
return (
|
||||
<div key={provider.name}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">{provider.name}</span>
|
||||
<span className="text-sm text-muted-foreground">{percentage}%</span>
|
||||
</div>
|
||||
<Progress value={percentage} className="h-2" />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<Dialog open={showApiDialog} onOpenChange={setShowApiDialog}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("创建API", "Create API")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"上传JSON文档或提供请求URL来创建API接口",
|
||||
"Upload JSON document or provide request URL to create API interface",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("API名称", "API Name")}</Label>
|
||||
<Input placeholder={t("输入API名称", "Enter API name")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("上传方式", "Upload Method")}</Label>
|
||||
<RadioGroup value={apiUploadMethod} onValueChange={(v: "file" | "url") => setApiUploadMethod(v)}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="file" id="file" />
|
||||
<Label htmlFor="file" className="font-normal cursor-pointer">
|
||||
{t("上传JSON文件", "Upload JSON File")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="url" id="url" />
|
||||
<Label htmlFor="url" className="font-normal cursor-pointer">
|
||||
{t("提供请求URL", "Provide Request URL")}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{apiUploadMethod === "file" ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("JSON文档", "JSON Document")}</Label>
|
||||
<div className="border-2 border-dashed rounded-lg p-6 text-center">
|
||||
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{jsonFile ? jsonFile.name : t("点击或拖拽上传JSON文件", "Click or drag to upload JSON file")}
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
id="json-upload"
|
||||
onChange={(e) => setJsonFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={() => document.getElementById("json-upload")?.click()}>
|
||||
{t("选择文件", "Select File")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("请求URL", "Request URL")}</Label>
|
||||
<Input placeholder="https://api.example.com/endpoint" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("请求方法", "Request Method")}</Label>
|
||||
<RadioGroup defaultValue="GET">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="GET" id="get" />
|
||||
<Label htmlFor="get" className="font-normal cursor-pointer">
|
||||
GET
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="POST" id="post" />
|
||||
<Label htmlFor="post" className="font-normal cursor-pointer">
|
||||
POST
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="PUT" id="put" />
|
||||
<Label htmlFor="put" className="font-normal cursor-pointer">
|
||||
PUT
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="DELETE" id="delete" />
|
||||
<Label htmlFor="delete" className="font-normal cursor-pointer">
|
||||
DELETE
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("请求参数 (JSON)", "Request Parameters (JSON)")}</Label>
|
||||
<Textarea
|
||||
placeholder={t("输入JSON格式的请求参数", "Enter request parameters in JSON format")}
|
||||
className="h-24 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("描述", "Description")}</Label>
|
||||
<Textarea placeholder={t("输入API描述...", "Enter API description...")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowApiDialog(false)}>
|
||||
{t("取消", "Cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => setShowApiDialog(false)}>{t("创建API", "Create API")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { DashboardLayout } from "@/components/dashboard-layout"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Network, Plus, Play, Save, Trash2, ArrowRight } from "lucide-react"
|
||||
import { useLanguage } from "@/hooks/useLanguage"
|
||||
import { TaijiAPIClient } from "@/lib/api-client"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
export default function OrchestrationPage() {
|
||||
const { t } = useLanguage()
|
||||
const { toast } = useToast()
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
const [workflowNodes, setWorkflowNodes] = useState<string[]>([])
|
||||
const [workflowName, setWorkflowName] = useState("")
|
||||
const [selectedGateway, setSelectedGateway] = useState<string>("")
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [availableAgents, setAvailableAgents] = useState<any[]>([])
|
||||
const [workflows, setWorkflows] = useState<any[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
// 并行加载Agent列表和工作流列表
|
||||
const [agentsResult] = await Promise.allSettled([
|
||||
TaijiAPIClient.getPlatformAgents(),
|
||||
// TODO: 添加获取工作流列表的API调用
|
||||
])
|
||||
|
||||
// 加载平台Agent列表
|
||||
if (agentsResult.status === "fulfilled" && agentsResult.value?.success) {
|
||||
const data = agentsResult.value.data?.data || []
|
||||
setAvailableAgents(data)
|
||||
}
|
||||
|
||||
// 加载工作流列表
|
||||
// TODO: 当后端API可用时,取消注释
|
||||
// if (workflowsResult.status === "fulfilled" && workflowsResult.value?.success) {
|
||||
// setWorkflows(workflowsResult.value.data || [])
|
||||
// }
|
||||
} catch (error) {
|
||||
console.error("Failed to load data:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const defaultAvailableAgents = [
|
||||
{ id: "weather-agent", name: t("天气查询Agent", "Weather Query Agent"), icon: "🌤️" },
|
||||
{ id: "data-analysis-agent", name: t("数据分析Agent", "Data Analysis Agent"), icon: "📊" },
|
||||
{ id: "doc-processor-agent", name: t("文档处理Agent", "Document Processing Agent"), icon: "📄" },
|
||||
{ id: "email-agent", name: t("邮件管理Agent", "Email Management Agent"), icon: "📧" },
|
||||
{ id: "api-integration-agent", name: t("API集成Agent", "API Integration Agent"), icon: "🔌" },
|
||||
{ id: "database-agent", name: t("数据库操作Agent", "Database Operations Agent"), icon: "🗄️" },
|
||||
{ id: "custom-agent-1", name: t("我的自定义Agent 1", "My Custom Agent 1"), icon: "⚙️", custom: true },
|
||||
{ id: "custom-agent-2", name: t("我的自定义Agent 2", "My Custom Agent 2"), icon: "⚙️", custom: true },
|
||||
]
|
||||
|
||||
// 使用真实数据或默认数据
|
||||
const agentsToUse = availableAgents.length > 0 ? availableAgents : defaultAvailableAgents
|
||||
|
||||
const addNode = (agentId: string) => {
|
||||
if (workflowNodes.length < 3 && !workflowNodes.includes(agentId)) {
|
||||
setWorkflowNodes([...workflowNodes, agentId])
|
||||
}
|
||||
}
|
||||
|
||||
const removeNode = (index: number) => {
|
||||
setWorkflowNodes(workflowNodes.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t("编排中心", "Orchestration Hub")}</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t("创建和管理Agent工作流(最多3个节点)", "Create and manage agent workflows (max 3 nodes)")}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateDialog(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("创建工作流", "Create Workflow")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("总工作流", "Total Workflows")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? (
|
||||
<span className="inline-block h-7 w-8 animate-pulse bg-muted rounded" />
|
||||
) : (
|
||||
workflows.length
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("已创建", "Created")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("运行中", "Running")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? (
|
||||
<span className="inline-block h-7 w-4 animate-pulse bg-muted rounded" />
|
||||
) : (
|
||||
workflows.filter((w) => w.status === "running").length
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("活跃工作流", "Active workflows")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("最大节点数", "Max Nodes")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">3</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("每个工作流", "Per workflow")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("可用Agent", "Available Agents")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? (
|
||||
<span className="inline-block h-7 w-8 animate-pulse bg-muted rounded" />
|
||||
) : (
|
||||
agentsToUse.length
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("平台和自定义", "Platform & Custom")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("我的工作流", "My Workflows")}</CardTitle>
|
||||
<CardDescription>{t("管理和执行Agent工作流", "Manage and execute agent workflows")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="h-20 animate-pulse bg-muted rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : workflows.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("暂无工作流", "No workflows yet")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{workflows.map((workflow) => (
|
||||
<div
|
||||
key={workflow.id}
|
||||
className="flex items-center justify-between rounded-lg border border-border bg-card p-4"
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Network className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="font-semibold">{workflow.name}</h3>
|
||||
<Badge
|
||||
className={
|
||||
workflow.status === "running"
|
||||
? "bg-green-500/10 text-green-500"
|
||||
: "bg-gray-500/10 text-gray-500"
|
||||
}
|
||||
>
|
||||
{workflow.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("节点数", "Nodes")}: {workflow.nodes.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="gap-2 bg-transparent">
|
||||
<Play className="h-4 w-4" />
|
||||
{t("运行", "Run")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("工作流限制", "Workflow Limitations")}</CardTitle>
|
||||
<CardDescription>{t("当前系统限制说明", "Current system limitations")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between rounded-lg bg-muted/30 p-3">
|
||||
<span className="text-sm font-medium">{t("最大Agent节点数", "Max Agent Nodes")}</span>
|
||||
<Badge variant="outline">3</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg bg-muted/30 p-3">
|
||||
<span className="text-sm font-medium">{t("支持的Agent类型", "Supported Agent Types")}</span>
|
||||
<Badge variant="outline">{t("平台和自定义", "Platform & Custom")}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg bg-muted/30 p-3">
|
||||
<span className="text-sm font-medium">{t("并发执行", "Concurrent Execution")}</span>
|
||||
<Badge variant="outline">{t("不支持", "Not Supported")}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 创建工作流对话框 */}
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("创建新工作流", "Create New Workflow")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("选择最多3个Agent来构建工作流", "Select up to 3 agents to build your workflow")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("工作流名称", "Workflow Name")}</Label>
|
||||
<Input
|
||||
placeholder={t("输入工作流名称", "Enter workflow name")}
|
||||
value={workflowName}
|
||||
onChange={(e) => setWorkflowName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("选择服务网关", "Select Service Gateway")}</Label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ id: "mcp", name: "MCP", desc: t("模型上下文协议", "Model Context Protocol") },
|
||||
{ id: "a2a", name: "A2A", desc: t("Agent到Agent通信", "Agent-to-Agent Communication") },
|
||||
{ id: "api", name: "API", desc: t("标准REST API", "Standard REST API") },
|
||||
].map((gateway) => (
|
||||
<button
|
||||
key={gateway.id}
|
||||
onClick={() => setSelectedGateway(gateway.id)}
|
||||
className={`p-4 rounded-lg border text-left transition-all ${
|
||||
selectedGateway === gateway.id
|
||||
? "border-primary bg-primary/5 shadow-sm"
|
||||
: "border-border hover:border-primary/50 hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold mb-1">{gateway.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{gateway.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("选择Agent(最多3个)", "Select Agents (max 3)")}</Label>
|
||||
<div className="grid grid-cols-2 gap-2 max-h-64 overflow-y-auto p-1">
|
||||
{agentsToUse.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
onClick={() => addNode(agent.id)}
|
||||
disabled={workflowNodes.length >= 3 || workflowNodes.includes(agent.id)}
|
||||
className="flex items-center gap-3 p-3 rounded-lg border border-border hover:border-primary hover:bg-primary/5 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-left"
|
||||
>
|
||||
<span className="text-2xl">{agent.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{agent.name}</p>
|
||||
{agent.custom && (
|
||||
<Badge variant="secondary" className="text-xs mt-1">
|
||||
{t("自定义", "Custom")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("工作流结构", "Workflow Structure")}</Label>
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-4 min-h-[120px]">
|
||||
{workflowNodes.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
{t("请从上方选择Agent添加到工作流", "Select agents from above to add to workflow")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{workflowNodes.map((nodeId, index) => {
|
||||
const agent = agentsToUse.find((a) => a.id === nodeId)
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="relative group">
|
||||
<div className="flex items-center gap-2 bg-primary/10 border border-primary/20 rounded-lg px-3 py-2">
|
||||
<span className="text-xl">{agent?.icon}</span>
|
||||
<span className="text-sm font-medium">{agent?.name}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeNode(index)}
|
||||
className="absolute -top-2 -right-2 h-5 w-5 rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-xs"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{index < workflowNodes.length - 1 && <ArrowRight className="h-5 w-5 text-primary" />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("已选择", "Selected")}: {workflowNodes.length}/3
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
|
||||
{t("取消", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (workflowNodes.length === 0 || !workflowName || !selectedGateway) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await TaijiAPIClient.createWorkflow({
|
||||
name: workflowName,
|
||||
gateway: selectedGateway as "MCP" | "A2A" | "API",
|
||||
nodes: workflowNodes.map((nodeId, index) => {
|
||||
const agent = agentsToUse.find((a) => a.id === nodeId)
|
||||
return {
|
||||
agentId: nodeId,
|
||||
agentType: agent?.type === "custom" ? "custom" : "platform",
|
||||
agentName: agent?.name || "",
|
||||
order: index + 1,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
if (result?.success) {
|
||||
toast({
|
||||
title: t("创建成功", "Success"),
|
||||
description: t("工作流已创建", "Workflow created"),
|
||||
})
|
||||
setShowCreateDialog(false)
|
||||
setWorkflowNodes([])
|
||||
setWorkflowName("")
|
||||
setSelectedGateway("")
|
||||
loadData()
|
||||
} else {
|
||||
throw new Error(result?.message || "Failed to create workflow")
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: t("创建失败", "Failed"),
|
||||
description: error.message || t("无法创建工作流", "Failed to create workflow"),
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}}
|
||||
disabled={workflowNodes.length === 0 || !workflowName || !selectedGateway}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{t("保存工作流", "Save Workflow")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { isAuthenticated } from "@/lib/auth"
|
||||
import { DashboardLayout } from "@/components/dashboard-layout"
|
||||
import { DashboardOverview } from "@/components/dashboard-overview"
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated()) {
|
||||
router.push("/login")
|
||||
}
|
||||
}, [router])
|
||||
|
||||
if (!isAuthenticated()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<DashboardOverview />
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useRouter, usePathname } from "next/navigation"
|
||||
import { isAuthenticated } from "@/lib/auth"
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: React.ReactNode
|
||||
requireAuth?: boolean
|
||||
redirectTo?: string
|
||||
}
|
||||
|
||||
export function AuthGuard({ children, requireAuth = true, redirectTo = "/login" }: AuthGuardProps) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
// 检查是否是登录页面
|
||||
const isLoginPage = pathname === "/login" || pathname === "/channel/login" || pathname === "/admin/login"
|
||||
|
||||
useEffect(() => {
|
||||
// 登录页面不需要认证
|
||||
if (isLoginPage) {
|
||||
// 如果已登录,重定向到首页
|
||||
if (isAuthenticated()) {
|
||||
if (pathname === "/channel/login") {
|
||||
router.push("/channel/dashboard")
|
||||
} else if (pathname === "/admin/login") {
|
||||
router.push("/admin/dashboard")
|
||||
} else {
|
||||
router.push("/")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 需要认证的页面
|
||||
if (requireAuth && !isAuthenticated()) {
|
||||
// 根据路径判断重定向到哪个登录页
|
||||
if (pathname.startsWith("/channel")) {
|
||||
router.push("/channel/login")
|
||||
} else if (pathname.startsWith("/admin")) {
|
||||
router.push("/admin/login")
|
||||
} else {
|
||||
router.push(redirectTo)
|
||||
}
|
||||
}
|
||||
}, [pathname, requireAuth, redirectTo, router, isLoginPage])
|
||||
|
||||
// 如果是登录页面且已登录,不渲染内容(等待重定向)
|
||||
if (isLoginPage && isAuthenticated()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 如果是登录页面,直接渲染(不需要认证)
|
||||
if (isLoginPage) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// 如果需要认证但未登录,不渲染内容(等待重定向)
|
||||
if (requireAuth && !isAuthenticated()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useLanguage } from "@/contexts/language-context"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Database,
|
||||
GitBranch,
|
||||
Boxes,
|
||||
Network,
|
||||
CreditCard,
|
||||
ChevronLeft,
|
||||
Bell,
|
||||
Settings,
|
||||
Activity,
|
||||
Zap,
|
||||
Globe,
|
||||
LogOut,
|
||||
User,
|
||||
Key,
|
||||
Copy,
|
||||
RefreshCw,
|
||||
} from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const navigationItems = [
|
||||
{ nameKey: { zh: "概览", en: "Overview" }, href: "/", icon: LayoutDashboard },
|
||||
{ nameKey: { zh: "服务网关", en: "Service Gateway" }, href: "/model-gateway", icon: GitBranch },
|
||||
{ nameKey: { zh: "数据与工具", en: "Data & Tools" }, href: "/data-tools", icon: Database },
|
||||
{ nameKey: { zh: "代理工厂", en: "Agent Factory" }, href: "/agent-factory", icon: Boxes },
|
||||
{ nameKey: { zh: "编排中心", en: "Orchestration Hub" }, href: "/orchestration", icon: Network },
|
||||
{ nameKey: { zh: "计费与资源", en: "Billing & Resources" }, href: "/billing", icon: CreditCard },
|
||||
]
|
||||
|
||||
export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const pathname = usePathname()
|
||||
const { language, setLanguage, t } = useLanguage()
|
||||
const [showApiKeyDialog, setShowApiKeyDialog] = useState(false)
|
||||
const [apiKey, setApiKey] = useState("sk_live_1234567890abcdefghijklmnopqrstuvwxyz")
|
||||
const [serviceEndpoint, setServiceEndpoint] = useState("https://api.taiji-ai.com/v1")
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
const regenerateApiKey = () => {
|
||||
const newKey = `sk_live_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
|
||||
setApiKey(newKey)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
"flex flex-col border-r border-border bg-card transition-all duration-300",
|
||||
collapsed ? "w-16" : "w-64",
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center justify-between border-b border-border px-4">
|
||||
{!collapsed && (
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
|
||||
<Zap className="h-5 w-5 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="text-lg font-semibold">Taiji AI</span>
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className={cn("h-8 w-8", collapsed && "mx-auto")}
|
||||
>
|
||||
<ChevronLeft className={cn("h-4 w-4 transition-transform", collapsed && "rotate-180")} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1 p-2 overflow-y-auto">
|
||||
{navigationItems.map((item) => {
|
||||
const isActive = pathname === item.href
|
||||
return (
|
||||
<Link key={item.href} href={item.href}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-5 w-5 shrink-0" />
|
||||
{!collapsed && <span>{t(item.nameKey.zh, item.nameKey.en)}</span>}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border p-2">
|
||||
<Button variant="ghost" className={cn("w-full justify-start", collapsed && "justify-center px-0")}>
|
||||
<Settings className="h-5 w-5 shrink-0" />
|
||||
{!collapsed && <span className="ml-3">{t("设置", "Settings")}</span>}
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Top Bar */}
|
||||
<header className="flex h-16 items-center justify-between border-b border-border bg-card px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm text-muted-foreground">{t("API 延迟", "API Latency")}: 45ms</span>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-border" />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<span className="text-sm text-muted-foreground">{t("所有系统正常运行", "All Systems Operational")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Language Switcher */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{language === "zh" ? "中文" : "English"}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setLanguage("zh")}>中文</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setLanguage("en")}>English</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Badge variant="outline" className="gap-2">
|
||||
<Zap className="h-3 w-3" />
|
||||
<span className="font-mono">2,487 EU</span>
|
||||
</Badge>
|
||||
<Button variant="ghost" size="icon" className="relative">
|
||||
<Bell className="h-5 w-5" />
|
||||
<span className="absolute right-1 top-1 h-2 w-2 rounded-full bg-destructive" />
|
||||
</Button>
|
||||
|
||||
{/* User Menu with Logout */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="rounded-full">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="bg-primary text-primary-foreground text-xs">AD</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
{t("个人资料", "Profile")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setShowApiKeyDialog(true)}>
|
||||
<Key className="mr-2 h-4 w-4" />
|
||||
{t("密钥管理", "API Keys")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
{t("设置", "Settings")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/login" className="flex items-center">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{t("退出登录", "Sign Out")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||
</div>
|
||||
|
||||
<Dialog open={showApiKeyDialog} onOpenChange={setShowApiKeyDialog}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("密钥管理", "API Key Management")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("查看和管理您的API密钥和服务终结点", "View and manage your API keys and service endpoints")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Service Endpoint */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("服务终结点", "Service Endpoint")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={serviceEndpoint} readOnly className="font-mono text-sm" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(serviceEndpoint)}
|
||||
title={t("复制", "Copy")}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("使用此终结点访问Taiji AI API服务", "Use this endpoint to access Taiji AI API services")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("API密钥", "API Key")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={apiKey} readOnly className="font-mono text-sm" type="password" />
|
||||
<Button variant="outline" size="icon" onClick={() => copyToClipboard(apiKey)} title={t("复制", "Copy")}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={regenerateApiKey} title={t("重新生成", "Regenerate")}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"请妥善保管您的API密钥。重新生成后,旧密钥将立即失效。",
|
||||
"Keep your API key secure. Regenerating will immediately invalidate the old key.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Usage Example */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("使用示例", "Usage Example")}</Label>
|
||||
<div className="rounded-md bg-muted p-3">
|
||||
<code className="text-xs">
|
||||
curl -X POST {serviceEndpoint}/chat/completions \\
|
||||
<br />
|
||||
-H "Authorization: Bearer {apiKey.substring(0, 20)}..." \\
|
||||
<br />
|
||||
-H "Content-Type: application/json" \\
|
||||
<br />
|
||||
-d '{"{"}"model": "gpt-4", "messages": [...]{"}"}'
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setShowApiKeyDialog(false)}>{t("关闭", "Close")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Activity, Zap, GitBranch, Boxes } from "lucide-react"
|
||||
import { useLanguage } from "@/contexts/language-context"
|
||||
import { TaijiAPIClient } from "@/lib/api-client"
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
AreaChart,
|
||||
Area,
|
||||
} from "recharts"
|
||||
|
||||
// 默认数据(加载时显示)
|
||||
const defaultEuConsumptionData = [
|
||||
{ time: "00:00", value: 0 },
|
||||
{ time: "04:00", value: 0 },
|
||||
{ time: "08:00", value: 0 },
|
||||
{ time: "12:00", value: 0 },
|
||||
{ time: "16:00", value: 0 },
|
||||
{ time: "20:00", value: 0 },
|
||||
{ time: "24:00", value: 0 },
|
||||
]
|
||||
|
||||
const defaultModelDistribution = [
|
||||
{ name: "GPT-4o", value: 0, color: "oklch(0.60 0.20 264)" },
|
||||
{ name: "Claude 3.5", value: 0, color: "oklch(0.65 0.16 220)" },
|
||||
{ name: "Llama-3", value: 0, color: "oklch(0.55 0.18 300)" },
|
||||
]
|
||||
|
||||
const defaultApiCallsData = [
|
||||
{ time: "Mon", calls: 0 },
|
||||
{ time: "Tue", calls: 0 },
|
||||
{ time: "Wed", calls: 0 },
|
||||
{ time: "Thu", calls: 0 },
|
||||
{ time: "Fri", calls: 0 },
|
||||
{ time: "Sat", calls: 0 },
|
||||
{ time: "Sun", calls: 0 },
|
||||
]
|
||||
|
||||
export function DashboardOverview() {
|
||||
const { t } = useLanguage()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [stats, setStats] = useState({
|
||||
activeAgents: 0,
|
||||
totalRequests: 0,
|
||||
euBalance: 0,
|
||||
systemHealth: 0,
|
||||
})
|
||||
const [euConsumptionData, setEuConsumptionData] = useState(defaultEuConsumptionData)
|
||||
const [modelDistribution, setModelDistribution] = useState(defaultModelDistribution)
|
||||
const [apiCallsData, setApiCallsData] = useState(defaultApiCallsData)
|
||||
const [systemComponents, setSystemComponents] = useState<Array<{ name: string; status: string; latency: string }>>([])
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardData()
|
||||
}, [])
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
// 并行加载数据
|
||||
const [dashboardStats, monitoringDashboard, billingBalance] = await Promise.allSettled([
|
||||
TaijiAPIClient.getUserDashboardStats(),
|
||||
TaijiAPIClient.getMonitoringDashboard(),
|
||||
TaijiAPIClient.getBillingBalance(),
|
||||
])
|
||||
|
||||
// 处理仪表板统计
|
||||
if (dashboardStats.status === "fulfilled" && dashboardStats.value?.success) {
|
||||
const data = dashboardStats.value.data
|
||||
setStats({
|
||||
activeAgents: data.activeAgents || 0,
|
||||
totalRequests: data.totalRequests || 0,
|
||||
euBalance: data.euBalance || 0,
|
||||
systemHealth: data.systemHealth || 0,
|
||||
})
|
||||
}
|
||||
|
||||
// 处理监控数据
|
||||
if (monitoringDashboard.status === "fulfilled" && monitoringDashboard.value) {
|
||||
const monitoring = monitoringDashboard.value
|
||||
// 可以从监控数据中提取系统组件状态
|
||||
if (monitoring.health?.services) {
|
||||
const components = Object.entries(monitoring.health.services).map(([name, status]) => ({
|
||||
name,
|
||||
status: status === "healthy" ? t("正常运行", "Operational") : t("异常", "Error"),
|
||||
latency: "0ms", // 可以从metrics中获取
|
||||
}))
|
||||
setSystemComponents(components)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理余额数据
|
||||
if (billingBalance.status === "fulfilled" && billingBalance.value?.success) {
|
||||
const balance = billingBalance.value.data
|
||||
setStats((prev) => ({
|
||||
...prev,
|
||||
euBalance: balance.euBalance || balance.balance || 0,
|
||||
}))
|
||||
}
|
||||
|
||||
// 加载趋势数据
|
||||
try {
|
||||
const trends = await TaijiAPIClient.getMonitoringTrends({
|
||||
metric: "eu_consumption",
|
||||
period: "24h",
|
||||
interval: "1h",
|
||||
})
|
||||
if (trends?.data) {
|
||||
// 转换趋势数据为图表格式
|
||||
const formatted = trends.data.map((item: any, index: number) => ({
|
||||
time: `${index * 4}:00`,
|
||||
value: item.count || 0,
|
||||
}))
|
||||
if (formatted.length > 0) {
|
||||
setEuConsumptionData(formatted)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load trends:", error)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to load dashboard data:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Hero Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card className="bg-card">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("活跃代理", "Active Agents")}
|
||||
</CardTitle>
|
||||
<Boxes className="h-4 w-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">
|
||||
{loading ? (
|
||||
<span className="inline-block h-8 w-16 animate-pulse bg-muted rounded" />
|
||||
) : (
|
||||
stats.activeAgents.toLocaleString()
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{loading ? "" : t("活跃代理", "Active Agents")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("全局 API 调用", "Global API Calls")}
|
||||
</CardTitle>
|
||||
<Activity className="h-4 w-4 text-secondary" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">
|
||||
{loading ? (
|
||||
<span className="inline-block h-8 w-20 animate-pulse bg-muted rounded" />
|
||||
) : (
|
||||
stats.totalRequests > 1000
|
||||
? `${(stats.totalRequests / 1000).toFixed(1)}K`
|
||||
: stats.totalRequests.toLocaleString()
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{loading ? "" : t("总请求数", "Total Requests")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("EU 余额", "EU Balance")}
|
||||
</CardTitle>
|
||||
<Zap className="h-4 w-4 text-chart-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">
|
||||
{loading ? (
|
||||
<span className="inline-block h-8 w-20 animate-pulse bg-muted rounded" />
|
||||
) : (
|
||||
stats.euBalance.toLocaleString()
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{loading ? "" : t("可用余额", "Available Balance")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("系统健康度", "System Health")}
|
||||
</CardTitle>
|
||||
<GitBranch className="h-4 w-4 text-chart-2" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">
|
||||
{loading ? (
|
||||
<span className="inline-block h-8 w-12 animate-pulse bg-muted rounded" />
|
||||
) : (
|
||||
`${stats.systemHealth}%`
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{loading ? "" : t("系统状态", "System Status")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* EU Consumption Chart */}
|
||||
<Card className="bg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("执行单元 (EU) 消耗", "Execution Unit (EU) Consumption")}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{t("过去 24 小时", "Last 24 hours")}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<AreaChart data={euConsumptionData}>
|
||||
<defs>
|
||||
<linearGradient id="colorEU" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="oklch(0.60 0.20 264)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="oklch(0.60 0.20 264)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="time" stroke="oklch(0.45 0 0)" fontSize={12} tickLine={false} />
|
||||
<YAxis stroke="oklch(0.45 0 0)" fontSize={12} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "oklch(0.15 0 0)",
|
||||
border: "1px solid oklch(0.22 0 0)",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="oklch(0.60 0.20 264)"
|
||||
strokeWidth={2}
|
||||
fill="url(#colorEU)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Model Distribution Chart */}
|
||||
<Card className="bg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("模型分布", "Model Distribution")}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{t("当前推理负载", "Current inference load")}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={modelDistribution}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={90}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{modelDistribution.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "oklch(0.15 0 0)",
|
||||
border: "1px solid oklch(0.22 0 0)",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="mt-4 grid grid-cols-3 gap-4">
|
||||
{modelDistribution.map((model) => (
|
||||
<div key={model.name} className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full" style={{ backgroundColor: model.color }} />
|
||||
<span className="text-xs font-medium">{model.value}%</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground mt-1">{model.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* API Calls Chart */}
|
||||
<Card className="bg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("每周 API 调用", "Weekly API Calls")}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{t("每日总调用次数", "Total calls per day")}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={apiCallsData}>
|
||||
<XAxis dataKey="time" stroke="oklch(0.45 0 0)" fontSize={12} tickLine={false} />
|
||||
<YAxis stroke="oklch(0.45 0 0)" fontSize={12} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "oklch(0.15 0 0)",
|
||||
border: "1px solid oklch(0.22 0 0)",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="calls"
|
||||
stroke="oklch(0.65 0.16 220)"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "oklch(0.65 0.16 220)", r: 4 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System Status */}
|
||||
<Card className="bg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("系统组件", "System Components")}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{t("实时状态监控", "Real-time status monitoring")}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{(systemComponents.length > 0
|
||||
? systemComponents
|
||||
: [
|
||||
{ name: "MCP Server", status: t("加载中", "Loading"), latency: "-" },
|
||||
{ name: "Data Ingestion", status: t("加载中", "Loading"), latency: "-" },
|
||||
{ name: "API Gateway", status: t("加载中", "Loading"), latency: "-" },
|
||||
]
|
||||
).map((component) => (
|
||||
<div
|
||||
key={component.name}
|
||||
className="flex items-center justify-between rounded-lg border border-border bg-muted/30 p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{component.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{component.latency}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-green-500">{component.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import {
|
||||
ThemeProvider as NextThemesProvider,
|
||||
type ThemeProviderProps,
|
||||
} from 'next-themes'
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion'
|
||||
import { ChevronDownIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn('border-b last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('pt-0 pb-4', className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
@@ -0,0 +1,157 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn('text-lg font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: 'outline' }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-card text-card-foreground',
|
||||
destructive:
|
||||
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
|
||||
},
|
||||
},
|
||||
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(
|
||||
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'
|
||||
|
||||
function AspectRatio({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
|
||||
}
|
||||
|
||||
export { AspectRatio }
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
|
||||
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(
|
||||
'bg-muted flex size-full items-center justify-center rounded-full',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'span'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,109 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { ChevronRight, MoreHorizontal } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
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(
|
||||
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words 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 : 'a'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn('hover:text-foreground transition-colors', 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('text-foreground font-normal', 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,83 @@
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
'[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
|
||||
vertical:
|
||||
'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: 'horizontal',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'div'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
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:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input 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',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,213 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from 'lucide-react'
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button, buttonVariants } from '@/components/ui/button'
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = 'label',
|
||||
buttonVariant = 'ghost',
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>['variant']
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
'bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className,
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString('default', { month: 'short' }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn('w-fit', defaultClassNames.root),
|
||||
months: cn(
|
||||
'flex gap-4 flex-col md:flex-row relative',
|
||||
defaultClassNames.months,
|
||||
),
|
||||
month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
|
||||
nav: cn(
|
||||
'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
|
||||
defaultClassNames.nav,
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
|
||||
defaultClassNames.button_previous,
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
|
||||
defaultClassNames.button_next,
|
||||
),
|
||||
month_caption: cn(
|
||||
'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
|
||||
defaultClassNames.month_caption,
|
||||
),
|
||||
dropdowns: cn(
|
||||
'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
|
||||
defaultClassNames.dropdowns,
|
||||
),
|
||||
dropdown_root: cn(
|
||||
'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
|
||||
defaultClassNames.dropdown_root,
|
||||
),
|
||||
dropdown: cn(
|
||||
'absolute bg-popover inset-0 opacity-0',
|
||||
defaultClassNames.dropdown,
|
||||
),
|
||||
caption_label: cn(
|
||||
'select-none font-medium',
|
||||
captionLayout === 'label'
|
||||
? 'text-sm'
|
||||
: 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
|
||||
defaultClassNames.caption_label,
|
||||
),
|
||||
table: 'w-full border-collapse',
|
||||
weekdays: cn('flex', defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
|
||||
defaultClassNames.weekday,
|
||||
),
|
||||
week: cn('flex w-full mt-2', defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
'select-none w-(--cell-size)',
|
||||
defaultClassNames.week_number_header,
|
||||
),
|
||||
week_number: cn(
|
||||
'text-[0.8rem] select-none text-muted-foreground',
|
||||
defaultClassNames.week_number,
|
||||
),
|
||||
day: cn(
|
||||
'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none',
|
||||
defaultClassNames.day,
|
||||
),
|
||||
range_start: cn(
|
||||
'rounded-l-md bg-accent',
|
||||
defaultClassNames.range_start,
|
||||
),
|
||||
range_middle: cn('rounded-none', defaultClassNames.range_middle),
|
||||
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
|
||||
today: cn(
|
||||
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
|
||||
defaultClassNames.today,
|
||||
),
|
||||
outside: cn(
|
||||
'text-muted-foreground aria-selected:text-muted-foreground',
|
||||
defaultClassNames.outside,
|
||||
),
|
||||
disabled: cn(
|
||||
'text-muted-foreground opacity-50',
|
||||
defaultClassNames.disabled,
|
||||
),
|
||||
hidden: cn('invisible', defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === 'left') {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn('size-4', className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === 'right') {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn('size-4', className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70',
|
||||
defaultClassNames.day,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 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-muted-foreground text-sm', 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,241 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from 'embla-carousel-react'
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useCarousel must be used within a <Carousel />')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
orientation = 'horizontal',
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & CarouselProps) {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === 'horizontal' ? 'x' : 'y',
|
||||
},
|
||||
plugins,
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) return
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) return
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return
|
||||
onSelect(api)
|
||||
api.on('reInit', onSelect)
|
||||
api.on('select', onSelect)
|
||||
|
||||
return () => {
|
||||
api?.off('select', onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn('relative', className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
data-slot="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden"
|
||||
data-slot="carousel-content"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex',
|
||||
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
const { orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-slot="carousel-item"
|
||||
className={cn(
|
||||
'min-w-0 shrink-0 grow-0 basis-full',
|
||||
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselPrevious({
|
||||
className,
|
||||
variant = 'outline',
|
||||
size = 'icon',
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
'absolute size-8 rounded-full',
|
||||
orientation === 'horizontal'
|
||||
? 'top-1/2 -left-12 -translate-y-1/2'
|
||||
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselNext({
|
||||
className,
|
||||
variant = 'outline',
|
||||
size = 'icon',
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
'absolute size-8 rounded-full',
|
||||
orientation === 'horizontal'
|
||||
? 'top-1/2 -right-12 -translate-y-1/2'
|
||||
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as RechartsPrimitive from 'recharts'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: '', dark: '.dark' } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
}
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useChart must be used within a <ChartContainer />')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>['children']
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color,
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join('\n')}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join('\n'),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = 'dot',
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<'div'> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: 'line' | 'dot' | 'dashed'
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === 'string'
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn('font-medium', labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn('font-medium', labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== 'dot'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || 'value'}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
|
||||
indicator === 'dot' && 'items-center',
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)',
|
||||
{
|
||||
'h-2.5 w-2.5': indicator === 'dot',
|
||||
'w-1': indicator === 'line',
|
||||
'w-0 border-[1.5px] border-dashed bg-transparent':
|
||||
indicator === 'dashed',
|
||||
'my-0.5': nestLabel && indicator === 'dashed',
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--color-bg': indicatorColor,
|
||||
'--color-border': indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 justify-between leading-none',
|
||||
nestLabel ? 'items-end' : 'items-center',
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = 'bottom',
|
||||
nameKey,
|
||||
}: React.ComponentProps<'div'> &
|
||||
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-4',
|
||||
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || 'value'}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={
|
||||
'[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3'
|
||||
}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string,
|
||||
) {
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
'payload' in payload &&
|
||||
typeof payload.payload === 'object' &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === 'string'
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||
import { CheckIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'
|
||||
|
||||
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,184 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Command as CommandPrimitive } from 'cmdk'
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = 'Command Palette',
|
||||
description = 'Search for a command to run...',
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn('overflow-hidden p-0', className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn('bg-border -mx-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
'text-muted-foreground ml-auto text-xs tracking-widest',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function ContextMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
data-slot="context-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: 'default' | 'destructive'
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none 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">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none 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">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn(
|
||||
'text-muted-foreground ml-auto text-xs tracking-widest',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { XIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn('text-lg leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Drawer as DrawerPrimitive } from 'vaul'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
'group/drawer-content bg-background fixed z-50 flex h-auto flex-col',
|
||||
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b',
|
||||
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t',
|
||||
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm',
|
||||
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
'flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn('text-foreground font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
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(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 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 p-1 shadow-md',
|
||||
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(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none 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(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none 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('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
'text-muted-foreground ml-auto text-xs tracking-widest',
|
||||
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(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
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(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Empty({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty"
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
className={cn(
|
||||
'flex max-w-sm flex-col items-center gap-2 text-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMediaVariants = cva(
|
||||
'flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function EmptyMedia({
|
||||
className,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
className={cn(emptyMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn('text-lg font-medium tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
'text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
className={cn(
|
||||
'flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
EmptyMedia,
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
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 [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]: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]:bg-primary/5 has-data-[state=checked]:border-primary 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-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
|
||||
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
|
||||
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||
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="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||
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) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (errors.length === 1 && errors[0]?.message) {
|
||||
return errors[0].message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{errors.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-destructive text-sm font-normal', className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from 'react-hook-form'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue,
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error('useFormField should be used within <FormField>')
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue,
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn('grid gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn('data-[error=true]:text-destructive', className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? '') : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn('text-destructive text-sm', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function HoverCard({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = 'center',
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
@@ -0,0 +1,169 @@
|
||||
'use client'
|
||||
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
|
||||
'h-9 has-[>textarea]:h-auto',
|
||||
|
||||
// Variants based on alignment.
|
||||
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
|
||||
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
|
||||
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
|
||||
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
|
||||
|
||||
// Focus state.
|
||||
'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]',
|
||||
|
||||
// Error state.
|
||||
'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
|
||||
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
'inline-start':
|
||||
'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
|
||||
'inline-end':
|
||||
'order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]',
|
||||
'block-start':
|
||||
'order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5',
|
||||
'block-end':
|
||||
'order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: 'inline-start',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = 'inline-start',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('button')) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector('input')?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
'text-sm shadow-none flex gap-2 items-center',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||
sm: 'h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5',
|
||||
'icon-xs':
|
||||
'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
|
||||
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'xs',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = 'button',
|
||||
variant = 'ghost',
|
||||
size = 'xs',
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, 'size'> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'textarea'>) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { OTPInput, OTPInputContext } from 'input-otp'
|
||||
import { MinusIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof OTPInput> & {
|
||||
containerClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn(
|
||||
'flex items-center gap-2 has-disabled:opacity-50',
|
||||
containerClassName,
|
||||
)}
|
||||
className={cn('disabled:cursor-not-allowed', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-group"
|
||||
className={cn('flex items-center', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
index: number
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext)
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
'data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,193 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
className={cn('group/item-group flex flex-col', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="item-separator"
|
||||
orientation="horizontal"
|
||||
className={cn('my-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
'group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a&]:hover:bg-accent/50 [a&]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
outline: 'border-border',
|
||||
muted: 'bg-muted/50',
|
||||
},
|
||||
size: {
|
||||
default: 'p-4 gap-4 ',
|
||||
sm: 'py-3 px-4 gap-2.5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> &
|
||||
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'div'
|
||||
return (
|
||||
<Comp
|
||||
data-slot="item"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(itemVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
'size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-content"
|
||||
className={cn(
|
||||
'flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-title"
|
||||
className={cn(
|
||||
'flex w-fit items-center gap-2 text-sm leading-snug font-medium',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
|
||||
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-actions"
|
||||
className={cn('flex items-center gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn(
|
||||
'flex basis-full items-center justify-between gap-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn(
|
||||
'flex basis-full items-center justify-between gap-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemActions,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemFooter,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd"
|
||||
className={cn(
|
||||
'bg-muted w-fit text-muted-foreground pointer-events-none inline-flex h-5 min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none',
|
||||
"[&_svg:not([class*='size-'])]:size-3",
|
||||
'[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd-group"
|
||||
className={cn('inline-flex items-center gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup }
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,276 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as MenubarPrimitive from '@radix-ui/react-menubar'
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Menubar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
|
||||
return (
|
||||
<MenubarPrimitive.Root
|
||||
data-slot="menubar"
|
||||
className={cn(
|
||||
'bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
||||
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
|
||||
}
|
||||
|
||||
function MenubarGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
||||
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
|
||||
}
|
||||
|
||||
function MenubarPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
||||
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
|
||||
}
|
||||
|
||||
function MenubarRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
|
||||
return (
|
||||
<MenubarPrimitive.Trigger
|
||||
data-slot="menubar-trigger"
|
||||
className={cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarContent({
|
||||
className,
|
||||
align = 'start',
|
||||
alignOffset = -4,
|
||||
sideOffset = 8,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
|
||||
return (
|
||||
<MenubarPortal>
|
||||
<MenubarPrimitive.Content
|
||||
data-slot="menubar-content"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: 'default' | 'destructive'
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.Item
|
||||
data-slot="menubar-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
data-slot="menubar-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none 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">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
|
||||
return (
|
||||
<MenubarPrimitive.RadioItem
|
||||
data-slot="menubar-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none 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">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.Label
|
||||
data-slot="menubar-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
|
||||
return (
|
||||
<MenubarPrimitive.Separator
|
||||
data-slot="menubar-separator"
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="menubar-shortcut"
|
||||
className={cn(
|
||||
'text-muted-foreground ml-auto text-xs tracking-widest',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
||||
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
|
||||
}
|
||||
|
||||
function MenubarSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
data-slot="menubar-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
|
||||
return (
|
||||
<MenubarPrimitive.SubContent
|
||||
data-slot="menubar-sub-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarPortal,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarGroup,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarItem,
|
||||
MenubarShortcut,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarSub,
|
||||
MenubarSubTrigger,
|
||||
MenubarSubContent,
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import * as React from 'react'
|
||||
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import { ChevronDownIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function NavigationMenu({
|
||||
className,
|
||||
children,
|
||||
viewport = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||
viewport?: boolean
|
||||
}) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
data-slot="navigation-menu"
|
||||
data-viewport={viewport}
|
||||
className={cn(
|
||||
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{viewport && <NavigationMenuViewport />}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn(
|
||||
'group flex flex-1 list-none items-center justify-center gap-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Item
|
||||
data-slot="navigation-menu-item"
|
||||
className={cn('relative', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
'group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1',
|
||||
)
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
className={cn(navigationMenuTriggerStyle(), 'group', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{' '}
|
||||
<ChevronDownIcon
|
||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
data-slot="navigation-menu-content"
|
||||
className={cn(
|
||||
'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto',
|
||||
'group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||
return (
|
||||
<div
|
||||
className={'absolute top-full left-0 isolate z-50 flex justify-center'}
|
||||
>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
className={cn(
|
||||
'origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuLink({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button, buttonVariants } from '@/components/ui/button'
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn('mx-auto flex w-full justify-center', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'ul'>) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn('flex flex-row items-center gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
|
||||
return <li data-slot="pagination-item" {...props} />
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
|
||||
React.ComponentProps<'a'>
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = 'icon',
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? 'outline' : 'ghost',
|
||||
size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn('gap-1 px-2.5 sm:pl-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span className="hidden sm:block">Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn('gap-1 px-2.5 sm:pr-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn('flex size-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = 'center',
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
|
||||
import { CircleIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn('grid gap-3', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { GripVerticalIcon } from 'lucide-react'
|
||||
import * as ResizablePrimitive from 'react-resizable-panels'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
'bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
)
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn('relative', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none p-px transition-colors select-none',
|
||||
orientation === 'vertical' &&
|
||||
'h-full w-2.5 border-l border-l-transparent',
|
||||
orientation === 'horizontal' &&
|
||||
'h-2.5 flex-col border-t border-t-transparent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,185 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
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(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = 'popper',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 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 shadow-md',
|
||||
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}
|
||||
{...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('text-muted-foreground px-2 py-1.5 text-xs', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground 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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span 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('bg-border pointer-events-none -mx-1 my-1 h-px', 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,28 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
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(
|
||||
'bg-border shrink-0 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,139 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog'
|
||||
import { XIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
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(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = 'right',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
side === 'right' &&
|
||||
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||
side === 'left' &&
|
||||
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||
side === 'top' &&
|
||||
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||
side === 'bottom' &&
|
||||
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<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('text-foreground font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, VariantProps } from 'class-variance-authority'
|
||||
import { PanelLeftIcon } from 'lucide-react'
|
||||
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
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 has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
|
||||
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(
|
||||
'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>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 text-sidebar-foreground hidden 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="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]: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(
|
||||
'hover:after:bg-sidebar-border 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] 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',
|
||||
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
|
||||
'[[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(
|
||||
'bg-background relative flex w-full flex-1 flex-col',
|
||||
'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('bg-background h-8 w-full 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('bg-sidebar-border mx-2 w-auto', 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 : 'div'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium 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 : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform 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 outline-hidden ring-sidebar-ring transition-[width,height,padding] 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 group-has-data-[sidebar=menu-action]/menu-item:pr-8 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 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>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_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(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 : '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 : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform 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 &&
|
||||
'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 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(
|
||||
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium 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(
|
||||
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l 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 : 'a'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 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',
|
||||
'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 '@/lib/utils'
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn('bg-accent animate-pulse rounded-md', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const _values = React.useMemo(
|
||||
() =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(defaultValue)
|
||||
? defaultValue
|
||||
: [min, max],
|
||||
[value, defaultValue, min, max],
|
||||
)
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className={
|
||||
'bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5'
|
||||
}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className={
|
||||
'bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full'
|
||||
}
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Slider }
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { useTheme } from 'next-themes'
|
||||
import { Toaster as Sonner, ToasterProps } from 'sonner'
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = 'system' } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps['theme']}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
'--normal-bg': 'var(--popover)',
|
||||
'--normal-text': 'var(--popover-foreground)',
|
||||
'--normal-border': 'var(--border)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Loader2Icon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<Loader2Icon
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn('size-4 animate-spin', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Spinner }
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={
|
||||
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0'
|
||||
}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn('[&_tr]:border-b', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'caption'>) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn('text-muted-foreground mt-4 text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn('flex flex-col gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn('flex-1 outline-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as ToastPrimitives from '@radix-ui/react-toast'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border bg-background text-foreground',
|
||||
destructive:
|
||||
'destructive group border-destructive bg-destructive text-destructive-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||
className,
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn('text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm opacity-90', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from '@/components/ui/toast'
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group'
|
||||
import { type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toggleVariants } from '@/components/ui/toggle'
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants>
|
||||
>({
|
||||
size: 'default',
|
||||
variant: 'default',
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(
|
||||
'group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
'min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as TogglePrimitive from '@radix-ui/react-toggle'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
outline:
|
||||
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-2 min-w-9',
|
||||
sm: 'h-8 px-1.5 min-w-8',
|
||||
lg: 'h-10 px-2.5 min-w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
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 (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from 'react'
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener('change', onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener('change', onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
'use client'
|
||||
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from 'react'
|
||||
|
||||
import type { ToastActionElement, ToastProps } from '@/components/ui/toast'
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: 'ADD_TOAST',
|
||||
UPDATE_TOAST: 'UPDATE_TOAST',
|
||||
DISMISS_TOAST: 'DISMISS_TOAST',
|
||||
REMOVE_TOAST: 'REMOVE_TOAST',
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType['ADD_TOAST']
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType['UPDATE_TOAST']
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType['DISMISS_TOAST']
|
||||
toastId?: ToasterToast['id']
|
||||
}
|
||||
| {
|
||||
type: ActionType['REMOVE_TOAST']
|
||||
toastId?: ToasterToast['id']
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: 'REMOVE_TOAST',
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'ADD_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case 'UPDATE_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t,
|
||||
),
|
||||
}
|
||||
|
||||
case 'DISMISS_TOAST': {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
}
|
||||
}
|
||||
case 'REMOVE_TOAST':
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, 'id'>
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: 'UPDATE_TOAST',
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: 'ADD_TOAST',
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { createContext, useContext, useState, useEffect } from "react"
|
||||
|
||||
type Language = "zh" | "en"
|
||||
|
||||
interface LanguageContextType {
|
||||
language: Language
|
||||
setLanguage: (lang: Language) => void
|
||||
t: (zh: string, en: string) => string
|
||||
}
|
||||
|
||||
const LanguageContext = createContext<LanguageContextType | undefined>(undefined)
|
||||
|
||||
export function LanguageProvider({ children }: { children: React.ReactNode }) {
|
||||
const [language, setLanguage] = useState<Language>("zh")
|
||||
|
||||
useEffect(() => {
|
||||
const savedLang = localStorage.getItem("language") as Language
|
||||
if (savedLang) {
|
||||
setLanguage(savedLang)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSetLanguage = (lang: Language) => {
|
||||
setLanguage(lang)
|
||||
localStorage.setItem("language", lang)
|
||||
}
|
||||
|
||||
const t = (zh: string, en: string) => {
|
||||
return language === "zh" ? zh : en
|
||||
}
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={{ language, setLanguage: handleSetLanguage, t }}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLanguage() {
|
||||
const context = useContext(LanguageContext)
|
||||
if (!context) {
|
||||
throw new Error("useLanguage must be used within a LanguageProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
# API 客户端对接完成总结
|
||||
|
||||
## ✅ 已完成的工作
|
||||
|
||||
### 1. API 客户端完整实现 (`lib/api-client.ts`)
|
||||
|
||||
已根据后端 API 接口文档完整实现了所有接口方法,包括:
|
||||
|
||||
#### 认证模块 API (6个方法)
|
||||
- ✅ `login()` - 用户登录
|
||||
- ✅ `logout()` - 用户登出
|
||||
- ✅ `refreshToken()` - 刷新Token
|
||||
- ✅ `changePassword()` - 修改密码
|
||||
- ✅ `getApiKeyInfo()` - 获取API密钥信息
|
||||
- ✅ `regenerateApiKey()` - 重新生成API密钥
|
||||
|
||||
#### 用户侧平台 API (14个方法)
|
||||
- ✅ `getUserDashboardStats()` - 获取仪表板统计
|
||||
- ✅ `getUserAgentActivity()` - 获取Agent活动数据
|
||||
- ✅ `selectGateway()` - 选择网关类型
|
||||
- ✅ `createGatewayAPI()` - 创建网关API
|
||||
- ✅ `getGatewayAPIs()` - 获取网关API列表
|
||||
- ✅ `getGatewayMonitoring()` - 获取网关监控数据
|
||||
- ✅ `generateTool()` - 生成工具
|
||||
- ✅ `createDataTemplate()` - 创建数据模板
|
||||
- ✅ `getPlatformAgents()` - 获取平台Agent列表
|
||||
- ✅ `deployAgent()` - 部署Agent
|
||||
- ✅ `createWorkflow()` - 创建工作流
|
||||
- ✅ `getBillingBalance()` - 获取余额信息
|
||||
- ✅ `rechargeBalance()` - 充值余额
|
||||
- ✅ `getBillingHistory()` - 获取计费历史
|
||||
|
||||
#### 渠道合作伙伴 API (8个方法)
|
||||
- ✅ `getChannelTenants()` - 获取租户列表
|
||||
- ✅ `createChannelTenant()` - 创建租户
|
||||
- ✅ `allocateTenantResources()` - 分配租户资源
|
||||
- ✅ `updateTenantBilling()` - 更新租户计费设置
|
||||
- ✅ `rechargeTenant()` - 为租户充值
|
||||
- ✅ `setTenantCreditLimit()` - 设置租户授信额度
|
||||
- ✅ `applyForResources()` - 申请资源
|
||||
- ✅ `getChannelBillingStats()` - 获取渠道计费统计
|
||||
|
||||
#### 超级管理员 API (10个方法)
|
||||
- ✅ `getAdminDashboardStats()` - 获取平台统计
|
||||
- ✅ `getAdminChannels()` - 获取渠道列表
|
||||
- ✅ `createAdminChannel()` - 创建渠道
|
||||
- ✅ `manageChannelResources()` - 统一管理渠道资源
|
||||
- ✅ `getAdminApplications()` - 获取所有申请
|
||||
- ✅ `reviewApplication()` - 审批申请
|
||||
- ✅ `getAdminModelProviders()` - 获取所有模型供应商
|
||||
- ✅ `getAdminAgentResources()` - 获取所有Agent资源
|
||||
- ✅ `getAdminAgentMonitoring()` - 监控Agent健康状态
|
||||
- ✅ `getAdminBillingOverview()` - 获取三维度计费统计
|
||||
|
||||
#### 供应商管理 API (6个方法)
|
||||
- ✅ `getModelProviders()` - 获取模型供应商列表
|
||||
- ✅ `createModelProvider()` - 创建模型供应商
|
||||
- ✅ `getProviderDetails()` - 获取供应商详情
|
||||
- ✅ `updateProvider()` - 更新供应商配置
|
||||
- ✅ `deleteProvider()` - 删除供应商
|
||||
- ✅ `testProviderConnection()` - 测试供应商连接
|
||||
|
||||
#### Data Ingestion 服务 API (12个方法)
|
||||
- ✅ `getHealth()` - 健康检查
|
||||
- ✅ `syncRapidAPI()` - 同步RapidAPI
|
||||
- ✅ `testRapidAPI()` - 测试RapidAPI端点
|
||||
- ✅ `parseOpenAPI()` - 解析OpenAPI规范
|
||||
- ✅ `processAPILLAMA()` - APILLAMA处理API文档
|
||||
- ✅ `generateTool()` - 生成工具定义
|
||||
- ✅ `getTools()` - 获取工具列表
|
||||
- ✅ `getTool()` - 获取特定工具定义
|
||||
- ✅ `deleteTool()` - 删除工具
|
||||
- ✅ `getStats()` - 获取统计信息
|
||||
- ✅ `clearCache()` - 清除缓存
|
||||
- ✅ `getMetrics()` - 获取Prometheus Metrics
|
||||
|
||||
#### MCP Server 服务 API (11个方法)
|
||||
- ✅ `getMCPHealth()` - MCP Server健康检查
|
||||
- ✅ `registerAgent()` - 注册Agent
|
||||
- ✅ `getAgents()` - 获取Agent列表
|
||||
- ✅ `getAgent()` - 获取特定Agent
|
||||
- ✅ `executeAgentTool()` - 执行Agent工具
|
||||
- ✅ `getMCPTools()` - 获取工具列表
|
||||
- ✅ `getMCPMetrics()` - 获取MCP Prometheus Metrics
|
||||
- ✅ `getMonitoringMetrics()` - 获取系统性能指标
|
||||
- ✅ `getMonitoringStats()` - 获取服务统计信息
|
||||
- ✅ `getMonitoringTrends()` - 获取性能趋势数据
|
||||
- ✅ `getMonitoringAlerts()` - 获取系统告警
|
||||
- ✅ `getMonitoringDashboard()` - 获取监控仪表盘聚合
|
||||
|
||||
#### WebSocket API (1个方法)
|
||||
- ✅ `createAgentWebSocket()` - 创建Agent WebSocket连接
|
||||
|
||||
### 2. 核心功能实现
|
||||
|
||||
#### 认证 Token 管理
|
||||
- ✅ 自动保存 Token 到 localStorage
|
||||
- ✅ 自动在请求头中添加 Authorization
|
||||
- ✅ 支持 JWT Token 和 API Key 双认证
|
||||
- ✅ 自动处理 Token 刷新
|
||||
|
||||
#### 错误处理
|
||||
- ✅ 统一的错误处理机制
|
||||
- ✅ 类型安全的响应处理
|
||||
- ✅ 友好的错误提示
|
||||
|
||||
#### 类型定义
|
||||
- ✅ 导出 `APIResponse` 类型供外部使用
|
||||
- ✅ 完整的 TypeScript 类型支持
|
||||
|
||||
### 3. 登录页面集成
|
||||
|
||||
- ✅ 更新登录页面使用新的 API 客户端
|
||||
- ✅ 添加错误处理和用户提示
|
||||
- ✅ 集成 toast 通知
|
||||
|
||||
### 4. 文档
|
||||
|
||||
- ✅ 创建 API 集成说明文档 (`docs/API_INTEGRATION.md`)
|
||||
- ✅ 包含所有 API 方法的使用示例
|
||||
- ✅ 包含错误处理、认证管理等说明
|
||||
|
||||
## 📋 使用示例
|
||||
|
||||
### 基本使用
|
||||
|
||||
```typescript
|
||||
import { TaijiAPIClient } from "@/lib/api-client"
|
||||
|
||||
// 登录
|
||||
const result = await TaijiAPIClient.login("user@example.com", "password", "user")
|
||||
|
||||
// 获取仪表板统计
|
||||
const stats = await TaijiAPIClient.getUserDashboardStats()
|
||||
|
||||
// 部署Agent
|
||||
await TaijiAPIClient.deployAgent({
|
||||
agentId: "agent-uuid",
|
||||
instances: 3,
|
||||
model: "gpt-4o-mini",
|
||||
gateway: "MCP"
|
||||
})
|
||||
```
|
||||
|
||||
## 🔧 配置
|
||||
|
||||
在 `.env.local` 文件中配置 API 端点:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_DATA_INGESTION_URL=http://localhost:8001
|
||||
NEXT_PUBLIC_MCP_SERVER_URL=http://localhost:8000
|
||||
NEXT_PUBLIC_API_GATEWAY_URL=http://localhost:80
|
||||
```
|
||||
|
||||
## 📝 注意事项
|
||||
|
||||
1. **环境变量**: 确保设置了正确的 API 端点 URL
|
||||
2. **CORS**: 开发环境需要后端配置 CORS
|
||||
3. **错误处理**: 所有 API 调用都应该有错误处理
|
||||
4. **Token 管理**: Token 自动管理,无需手动处理
|
||||
5. **类型安全**: 使用 TypeScript 类型检查
|
||||
|
||||
## 🚀 下一步
|
||||
|
||||
1. 在各个页面组件中集成 API 调用
|
||||
2. 替换现有的模拟数据为真实 API 调用
|
||||
3. 添加加载状态和错误处理
|
||||
4. 实现数据缓存和优化
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- API 集成说明: `docs/API_INTEGRATION.md`
|
||||
- 后端 API 文档: `/tools/taiji-AI-PAD/Docs/前后端调试说明/API接口文档.md`
|
||||
- 后端需求文档: `docs/backend-api-requirements.md`
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
# API 集成说明
|
||||
|
||||
本文档说明如何在前端项目中使用后端 API 接口。
|
||||
|
||||
## 环境配置
|
||||
|
||||
在项目根目录创建 `.env.local` 文件(或使用 `.env.example` 作为模板):
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_DATA_INGESTION_URL=http://localhost:8001
|
||||
NEXT_PUBLIC_MCP_SERVER_URL=http://localhost:8000
|
||||
NEXT_PUBLIC_API_GATEWAY_URL=http://localhost:80
|
||||
```
|
||||
|
||||
## API 客户端使用
|
||||
|
||||
### 导入 API 客户端
|
||||
|
||||
```typescript
|
||||
import { TaijiAPIClient } from "@/lib/api-client"
|
||||
```
|
||||
|
||||
### 认证相关
|
||||
|
||||
#### 登录
|
||||
```typescript
|
||||
try {
|
||||
const result = await TaijiAPIClient.login("user@example.com", "password", "user")
|
||||
if (result.success) {
|
||||
// Token 已自动保存到 localStorage
|
||||
console.log("登录成功", result.data.user)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("登录失败", error)
|
||||
}
|
||||
```
|
||||
|
||||
#### 登出
|
||||
```typescript
|
||||
await TaijiAPIClient.logout()
|
||||
// Token 已自动清除
|
||||
```
|
||||
|
||||
#### 刷新 Token
|
||||
```typescript
|
||||
await TaijiAPIClient.refreshToken()
|
||||
```
|
||||
|
||||
### 用户侧平台 API
|
||||
|
||||
#### 获取仪表板统计
|
||||
```typescript
|
||||
const stats = await TaijiAPIClient.getUserDashboardStats()
|
||||
console.log(stats.data.activeAgents)
|
||||
```
|
||||
|
||||
#### 部署 Agent
|
||||
```typescript
|
||||
const result = await TaijiAPIClient.deployAgent({
|
||||
agentId: "agent-uuid",
|
||||
instances: 3,
|
||||
model: "gpt-4o-mini",
|
||||
gateway: "MCP"
|
||||
})
|
||||
```
|
||||
|
||||
#### 创建工作流
|
||||
```typescript
|
||||
const workflow = await TaijiAPIClient.createWorkflow({
|
||||
name: "订单处理流程",
|
||||
gateway: "MCP",
|
||||
nodes: [
|
||||
{
|
||||
agentId: "agent-1",
|
||||
agentType: "platform",
|
||||
agentName: "订单验证Agent",
|
||||
order: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
#### 获取计费历史
|
||||
```typescript
|
||||
const history = await TaijiAPIClient.getBillingHistory({
|
||||
startTime: "2025-01-01T00:00:00Z",
|
||||
endTime: "2025-01-31T23:59:59Z",
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
})
|
||||
```
|
||||
|
||||
### 渠道合作伙伴 API
|
||||
|
||||
#### 获取租户列表
|
||||
```typescript
|
||||
const tenants = await TaijiAPIClient.getChannelTenants()
|
||||
```
|
||||
|
||||
#### 创建租户
|
||||
```typescript
|
||||
const tenant = await TaijiAPIClient.createChannelTenant({
|
||||
name: "企业客户A",
|
||||
email: "contact@company-a.com",
|
||||
password: "securepass123",
|
||||
subscriptionTier: "enterprise"
|
||||
})
|
||||
```
|
||||
|
||||
#### 分配租户资源
|
||||
```typescript
|
||||
await TaijiAPIClient.allocateTenantResources("tenant-id", {
|
||||
agents: [
|
||||
{ agentId: "agent-1", quantity: 10 }
|
||||
],
|
||||
models: [
|
||||
{ modelName: "gpt-4", rpm: 10000, tpm: 500000 }
|
||||
],
|
||||
customAgentResources: {
|
||||
cpu: 2.0,
|
||||
memory: 4.0
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 超级管理员 API
|
||||
|
||||
#### 获取平台统计
|
||||
```typescript
|
||||
const stats = await TaijiAPIClient.getAdminDashboardStats()
|
||||
```
|
||||
|
||||
#### 创建渠道
|
||||
```typescript
|
||||
const channel = await TaijiAPIClient.createAdminChannel({
|
||||
name: "合作渠道A",
|
||||
email: "partner@channel-a.com",
|
||||
password: "channelpass123",
|
||||
commissionRate: 10.0
|
||||
})
|
||||
```
|
||||
|
||||
#### 审批申请
|
||||
```typescript
|
||||
await TaijiAPIClient.reviewApplication("application-id", true, "审批通过")
|
||||
```
|
||||
|
||||
### 供应商管理 API
|
||||
|
||||
#### 获取模型供应商列表
|
||||
```typescript
|
||||
const providers = await TaijiAPIClient.getModelProviders()
|
||||
```
|
||||
|
||||
#### 创建模型供应商
|
||||
```typescript
|
||||
const provider = await TaijiAPIClient.createModelProvider({
|
||||
name: "OpenAI",
|
||||
provider: "openai",
|
||||
apiUrl: "https://api.openai.com/v1",
|
||||
apiKey: "sk-xxxxx",
|
||||
supportedModels: ["gpt-4", "gpt-4o-mini"],
|
||||
rpm: 3500,
|
||||
tpm: 90000
|
||||
})
|
||||
```
|
||||
|
||||
### Data Ingestion 服务 API
|
||||
|
||||
#### 获取健康状态
|
||||
```typescript
|
||||
const health = await TaijiAPIClient.getHealth()
|
||||
```
|
||||
|
||||
#### 同步 RapidAPI
|
||||
```typescript
|
||||
await TaijiAPIClient.syncRapidAPI("weather", 100)
|
||||
```
|
||||
|
||||
#### 处理 APILLAMA
|
||||
```typescript
|
||||
const result = await TaijiAPIClient.processAPILLAMA(
|
||||
{ title: "Weather API", description: "Get weather" },
|
||||
{ service: "Weather service" },
|
||||
"json_schema"
|
||||
)
|
||||
```
|
||||
|
||||
#### 获取工具列表
|
||||
```typescript
|
||||
const tools = await TaijiAPIClient.getTools("weather", 100, 0)
|
||||
```
|
||||
|
||||
### MCP Server API
|
||||
|
||||
#### 注册 Agent
|
||||
```typescript
|
||||
const agent = await TaijiAPIClient.registerAgent({
|
||||
name: "weather-agent",
|
||||
description: "Weather information agent",
|
||||
capabilities: ["weather_query"]
|
||||
})
|
||||
```
|
||||
|
||||
#### 获取 Agent 列表
|
||||
```typescript
|
||||
const agents = await TaijiAPIClient.getAgents(0, 100)
|
||||
```
|
||||
|
||||
#### 执行 Agent 工具
|
||||
```typescript
|
||||
const result = await TaijiAPIClient.executeAgentTool("agent-id", {
|
||||
method: "tools/call",
|
||||
params: {
|
||||
tool: { name: "math_add", function_name: "math_add" },
|
||||
arguments: { a: 10, b: 20 }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### WebSocket 连接
|
||||
|
||||
```typescript
|
||||
const ws = TaijiAPIClient.createAgentWebSocket("agent-id")
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("WebSocket connected")
|
||||
ws.send(JSON.stringify({
|
||||
type: "mcp_request",
|
||||
payload: {
|
||||
method: "tools/list",
|
||||
params: {}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
console.log("Received:", data)
|
||||
}
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
所有 API 方法都会抛出错误,建议使用 try-catch 处理:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const result = await TaijiAPIClient.getUserDashboardStats()
|
||||
// 处理成功结果
|
||||
} catch (error: any) {
|
||||
console.error("API Error:", error.message)
|
||||
// 显示错误提示给用户
|
||||
}
|
||||
```
|
||||
|
||||
## 认证 Token 管理
|
||||
|
||||
API 客户端会自动管理认证 Token:
|
||||
|
||||
- **登录时**: Token 自动保存到 `localStorage`
|
||||
- **请求时**: Token 自动添加到请求头 `Authorization: Bearer <token>`
|
||||
- **登出时**: Token 自动清除
|
||||
|
||||
如果需要手动获取 Token:
|
||||
|
||||
```typescript
|
||||
const token = localStorage.getItem("auth_token")
|
||||
```
|
||||
|
||||
## API Key 认证
|
||||
|
||||
除了 JWT Token,还支持 API Key 认证:
|
||||
|
||||
```typescript
|
||||
// 设置 API Key
|
||||
localStorage.setItem("api_key", "sk-xxxxx")
|
||||
|
||||
// API 客户端会自动使用 API Key
|
||||
const result = await TaijiAPIClient.getUserDashboardStats()
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **环境变量**: 确保设置了正确的 API 端点 URL
|
||||
2. **CORS**: 开发环境需要后端配置 CORS 允许前端域名
|
||||
3. **错误处理**: 所有 API 调用都应该有错误处理
|
||||
4. **Token 过期**: 如果 Token 过期,需要重新登录或刷新 Token
|
||||
5. **类型安全**: API 客户端使用 TypeScript,建议启用类型检查
|
||||
|
||||
## 相关文档
|
||||
|
||||
- 后端 API 接口文档: `/tools/taiji-AI-PAD/Docs/前后端调试说明/API接口文档.md`
|
||||
- 后端需求文档: `/tools/taiji-pad-v0/docs/backend-api-requirements.md`
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
import * as React from 'react'
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener('change', onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener('change', onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
'use client'
|
||||
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from 'react'
|
||||
|
||||
import type { ToastActionElement, ToastProps } from '@/components/ui/toast'
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: 'ADD_TOAST',
|
||||
UPDATE_TOAST: 'UPDATE_TOAST',
|
||||
DISMISS_TOAST: 'DISMISS_TOAST',
|
||||
REMOVE_TOAST: 'REMOVE_TOAST',
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType['ADD_TOAST']
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType['UPDATE_TOAST']
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType['DISMISS_TOAST']
|
||||
toastId?: ToasterToast['id']
|
||||
}
|
||||
| {
|
||||
type: ActionType['REMOVE_TOAST']
|
||||
toastId?: ToasterToast['id']
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: 'REMOVE_TOAST',
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'ADD_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case 'UPDATE_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t,
|
||||
),
|
||||
}
|
||||
|
||||
case 'DISMISS_TOAST': {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
}
|
||||
}
|
||||
case 'REMOVE_TOAST':
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, 'id'>
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: 'UPDATE_TOAST',
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: 'ADD_TOAST',
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
@@ -0,0 +1 @@
|
||||
export { useLanguage } from "@/contexts/language-context"
|
||||
+1102
File diff suppressed because it is too large
Load Diff
+41
@@ -0,0 +1,41 @@
|
||||
// Authentication utilities
|
||||
|
||||
export function getAuthToken(): string | null {
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem("auth_token")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function getUser(): any | null {
|
||||
if (typeof window !== "undefined") {
|
||||
const userStr = localStorage.getItem("user")
|
||||
if (userStr) {
|
||||
try {
|
||||
return JSON.parse(userStr)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return getAuthToken() !== null
|
||||
}
|
||||
|
||||
export function clearAuth(): void {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("auth_token")
|
||||
localStorage.removeItem("refresh_token")
|
||||
localStorage.removeItem("user")
|
||||
localStorage.removeItem("api_key")
|
||||
}
|
||||
}
|
||||
|
||||
export function getRole(): string | null {
|
||||
const user = getUser()
|
||||
return user?.role || null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"name": "my-v0-project",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"dev": "next dev",
|
||||
"lint": "eslint .",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@radix-ui/react-accordion": "1.2.2",
|
||||
"@radix-ui/react-alert-dialog": "1.1.4",
|
||||
"@radix-ui/react-aspect-ratio": "1.1.1",
|
||||
"@radix-ui/react-avatar": "1.1.2",
|
||||
"@radix-ui/react-checkbox": "1.1.3",
|
||||
"@radix-ui/react-collapsible": "1.1.2",
|
||||
"@radix-ui/react-context-menu": "2.2.4",
|
||||
"@radix-ui/react-dialog": "1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.4",
|
||||
"@radix-ui/react-hover-card": "1.1.4",
|
||||
"@radix-ui/react-label": "2.1.1",
|
||||
"@radix-ui/react-menubar": "1.1.4",
|
||||
"@radix-ui/react-navigation-menu": "1.2.3",
|
||||
"@radix-ui/react-popover": "1.1.4",
|
||||
"@radix-ui/react-progress": "1.1.1",
|
||||
"@radix-ui/react-radio-group": "1.2.2",
|
||||
"@radix-ui/react-scroll-area": "1.2.2",
|
||||
"@radix-ui/react-select": "2.1.4",
|
||||
"@radix-ui/react-separator": "1.1.1",
|
||||
"@radix-ui/react-slider": "1.2.2",
|
||||
"@radix-ui/react-slot": "1.1.1",
|
||||
"@radix-ui/react-switch": "1.1.2",
|
||||
"@radix-ui/react-tabs": "1.1.2",
|
||||
"@radix-ui/react-toast": "1.2.4",
|
||||
"@radix-ui/react-toggle": "1.1.1",
|
||||
"@radix-ui/react-toggle-group": "1.1.1",
|
||||
"@radix-ui/react-tooltip": "1.1.6",
|
||||
"@vercel/analytics": "1.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.0.4",
|
||||
"date-fns": "4.1.0",
|
||||
"embla-carousel-react": "8.5.1",
|
||||
"input-otp": "1.4.1",
|
||||
"lucide-react": "^0.454.0",
|
||||
"next": "16.0.10",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.0",
|
||||
"react-day-picker": "9.8.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "2.15.4",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.9",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"postcss": "^8.5",
|
||||
"tailwindcss": "^4.1.9",
|
||||
"tw-animate-css": "1.3.3",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
Generated
+3180
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 585 B |
Binary file not shown.
|
After Width: | Height: | Size: 566 B |
@@ -0,0 +1,26 @@
|
||||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
@media (prefers-color-scheme: light) {
|
||||
.background { fill: black; }
|
||||
.foreground { fill: white; }
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.background { fill: white; }
|
||||
.foreground { fill: black; }
|
||||
}
|
||||
</style>
|
||||
<g clip-path="url(#clip0_7960_43945)">
|
||||
<rect class="background" width="180" height="180" rx="37" />
|
||||
<g style="transform: scale(95%); transform-origin: center">
|
||||
<path class="foreground"
|
||||
d="M101.141 53H136.632C151.023 53 162.689 64.6662 162.689 79.0573V112.904H148.112V79.0573C148.112 78.7105 148.098 78.3662 148.072 78.0251L112.581 112.898C112.701 112.902 112.821 112.904 112.941 112.904H148.112V126.672H112.941C98.5504 126.672 86.5638 114.891 86.5638 100.5V66.7434H101.141V100.5C101.141 101.15 101.191 101.792 101.289 102.422L137.56 66.7816C137.255 66.7563 136.945 66.7434 136.632 66.7434H101.141V53Z" />
|
||||
<path class="foreground"
|
||||
d="M65.2926 124.136L14 66.7372H34.6355L64.7495 100.436V66.7372H80.1365V118.47C80.1365 126.278 70.4953 129.958 65.2926 124.136Z" />
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_7960_43945">
|
||||
<rect width="180" height="180" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 568 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="215" height="48" fill="none"><path fill="#000" d="M57.588 9.6h6L73.828 38h-5.2l-2.36-6.88h-11.36L52.548 38h-5.2l10.24-28.4Zm7.16 17.16-4.16-12.16-4.16 12.16h8.32Zm23.694-2.24c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.486-7.72.12 3.4c.534-1.227 1.307-2.173 2.32-2.84 1.04-.693 2.267-1.04 3.68-1.04 1.494 0 2.76.387 3.8 1.16 1.067.747 1.827 1.813 2.28 3.2.507-1.44 1.294-2.52 2.36-3.24 1.094-.747 2.414-1.12 3.96-1.12 1.414 0 2.64.307 3.68.92s1.84 1.52 2.4 2.72c.56 1.2.84 2.667.84 4.4V38h-4.96V25.92c0-1.813-.293-3.187-.88-4.12-.56-.96-1.413-1.44-2.56-1.44-.906 0-1.68.213-2.32.64-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.84-.48 3.04V38h-4.56V25.92c0-1.2-.133-2.213-.4-3.04-.24-.827-.626-1.453-1.16-1.88-.506-.427-1.133-.64-1.88-.64-.906 0-1.68.227-2.32.68-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.827-.48 3V38h-4.96V16.8h4.48Zm26.723 10.6c0-2.24.427-4.187 1.28-5.84.854-1.68 2.067-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.84 0 3.494.413 4.96 1.24 1.467.827 2.64 2.08 3.52 3.76.88 1.653 1.347 3.693 1.4 6.12v1.32h-15.08c.107 1.813.614 3.227 1.52 4.24.907.987 2.134 1.48 3.68 1.48.987 0 1.88-.253 2.68-.76a4.803 4.803 0 0 0 1.84-2.2l5.08.36c-.64 2.027-1.84 3.64-3.6 4.84-1.733 1.173-3.733 1.76-6 1.76-2.08 0-3.906-.453-5.48-1.36-1.573-.907-2.786-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84Zm15.16-2.04c-.213-1.733-.76-3.013-1.64-3.84-.853-.827-1.893-1.24-3.12-1.24-1.44 0-2.6.453-3.48 1.36-.88.88-1.44 2.12-1.68 3.72h9.92ZM163.139 9.6V38h-5.04V9.6h5.04Zm8.322 7.2.24 5.88-.64-.36c.32-2.053 1.094-3.56 2.32-4.52 1.254-.987 2.787-1.48 4.6-1.48 2.32 0 4.107.733 5.36 2.2 1.254 1.44 1.88 3.387 1.88 5.84V38h-4.96V25.92c0-1.253-.12-2.28-.36-3.08-.24-.8-.64-1.413-1.2-1.84-.533-.427-1.253-.64-2.16-.64-1.44 0-2.573.48-3.4 1.44-.8.933-1.2 2.307-1.2 4.12V38h-4.96V16.8h4.48Zm30.003 7.72c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.443 8.16V38h-5.6v-5.32h5.6Z"/><path fill="#171717" fill-rule="evenodd" d="m7.839 40.783 16.03-28.054L20 6 0 40.783h7.839Zm8.214 0H40L27.99 19.894l-4.02 7.032 3.976 6.914H20.02l-3.967 6.943Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user