From bd657f329cadeb054f4e7ffea60feea303a18a4e Mon Sep 17 00:00:00 2001 From: BingZi-233 Date: Tue, 11 Nov 2025 21:11:37 +0800 Subject: [PATCH 001/172] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E4=BB=AA?= =?UTF-8?q?=E8=A1=A8=E7=9B=98=E6=95=B0=E6=8D=AE=E8=87=AA=E5=8A=A8=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E5=B9=B6=E5=AE=8C=E5=96=84=E9=A1=B9=E7=9B=AE=E6=9E=B6?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 集成 shadcn/ui 组件库及相关依赖(@lobehub/icons, lucide-react, class-variance-authority 等) - 实现核心业务逻辑模块: • checks.ts: API 健康检查逻辑 • poller.ts: 自动轮询器,支持自定义刷新间隔 • status.ts: 状态管理 • history-store.ts: 历史记录持久化 • polling-config.ts: 轮询配置管理 - 完善 README 文档,添加功能说明、环境变量配置指南和快速开始步骤 - 更新全局样式,支持明暗主题切换 - 配置数据持久化,将运行时历史数据(data/check-history.json)排除版本控制 - 更新页面元数据和语言设置为中文 --- .gitignore | 3 + README.md | 94 +- app/api/dashboard/route.ts | 11 + app/globals.css | 122 +- app/layout.tsx | 7 +- app/page.tsx | 70 +- components.json | 22 + components/dashboard-view.tsx | 187 ++ components/provider-icon.tsx | 24 + components/status-timeline.tsx | 86 + components/ui/badge.tsx | 37 + components/ui/button.tsx | 48 + components/ui/card.tsx | 64 + components/ui/table.tsx | 117 + lib/checks.ts | 308 ++ lib/dashboard-data.ts | 124 + lib/history-store.ts | 78 + lib/poller.ts | 35 + lib/polling-config.ts | 30 + lib/status.ts | 36 + lib/utils.ts | 6 + package.json | 16 +- pnpm-lock.yaml | 5692 +++++++++++++++++++++++++++++++- 23 files changed, 6935 insertions(+), 282 deletions(-) create mode 100644 app/api/dashboard/route.ts create mode 100644 components.json create mode 100644 components/dashboard-view.tsx create mode 100644 components/provider-icon.tsx create mode 100644 components/status-timeline.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/table.tsx create mode 100644 lib/checks.ts create mode 100644 lib/dashboard-data.ts create mode 100644 lib/history-store.ts create mode 100644 lib/poller.ts create mode 100644 lib/polling-config.ts create mode 100644 lib/status.ts create mode 100644 lib/utils.ts diff --git a/.gitignore b/.gitignore index 5ef6a52..8b16e53 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# runtime data +data/check-history.json diff --git a/README.md b/README.md index e215bc4..fcfc076 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,84 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +## Check CX -## Getting Started +Check CX 是一套基于 Next.js + shadcn/ui 的 AI 对话健康面板,用于持续监控 OpenAI、Gemini、Anthropic 等模型的 API 可用性、延迟与错误信息,可直接部署为落地页或团队内部状态墙。 -First, run the development server: +### 功能亮点 -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` +- 🎯 **多目标配置**:通过 `.env` 声明端点、密钥、类型与模型,支持任意数量的检测组 +- ⏱️ **分钟级采样**:Node 侧常驻轮询器按配置频率触发对话请求,并将 1 小时内的状态写入本地缓存 +- ⚙️ **可调频率**:`CHECK_POLL_INTERVAL_SECONDS` 支持 15~600 秒自定义检测周期(默认 60 秒) +- 📈 **时间轴视图**:每个配置都会渲染独立时间轴,可快速对比 60 次内的成功/失败/延迟 +- 🔒 **安全默认**:密钥仅在服务器侧读取并用于后端请求,不会透传到浏览器 + +## 快速开始 + +1. 安装依赖 + + ```bash + pnpm install + ``` + +2. 复制并修改环境变量 + + ```bash + cp .env.example .env.local + ``` + +3. 启动本地开发 -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + ```bash + pnpm dev + ``` -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +4. 访问 [http://localhost:3000](http://localhost:3000) 查看状态面板。 -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +## 数据采集与存储 -## Learn More +- 所有检测均由服务器发起:`lib/poller.ts` 会在进程启动后立即检测一次,并按 `CHECK_POLL_INTERVAL_SECONDS` 间隔持续轮询(默认 60 秒,可自定义)。 +- 检测结果会写入 `data/check-history.json`,默认仅保留最近 1 小时(最多 60 条)记录,用于渲染时间轴。 +- `data/check-history.json` 已加入 `.gitignore`,不会将历史数据或调试密钥提交到仓库。 -To learn more about Next.js, take a look at the following resources: +## 环境变量格式 -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +使用 `CHECK_GROUPS` 指定需要检测的配置标识(英文逗号分隔)。除默认变量外,每个标识都需要以下字段: -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +| 变量名 | 说明 | +| ------------------------------ | ----------------------------------------------------------------------------------- | +| `CHECK_POLL_INTERVAL_SECONDS` | (可选) 全局检测间隔(单位秒,默认 60,支持 15~600) | +| `CHECK__NAME` | (可选) 展示名称,缺省时使用标识本身 | +| `CHECK__TYPE` | 提供商类型:`openai` / `gemini` / `anthropic` | +| `CHECK__KEY` | 对应提供商的 API Key | +| `CHECK__MODEL` | 模型名称,如 `gpt-4o-mini`、`gemini-1.5-flash`、`claude-3-5-sonnet-latest` | +| `CHECK__ENDPOINT` | (可选) 自定义端点,未配置时使用官方默认地址,可指向代理或企业专线 | -## Deploy on Vercel +示例: -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +``` +CHECK_POLL_INTERVAL_SECONDS=60 +CHECK_GROUPS=main,backup,gemini,claude + +CHECK_MAIN_NAME=主力 OpenAI +CHECK_MAIN_TYPE=openai +CHECK_MAIN_KEY=sk-xxxxx +CHECK_MAIN_MODEL=gpt-4o-mini +CHECK_MAIN_ENDPOINT=https://api.openai.com/v1/chat/completions + +CHECK_BACKUP_NAME=备用 OpenAI +CHECK_BACKUP_TYPE=openai +CHECK_BACKUP_KEY=sk-yyyyy +CHECK_BACKUP_MODEL=gpt-4o-mini +CHECK_BACKUP_ENDPOINT=https://api.openai.com/v1/chat/completions + +CHECK_GEMINI_NAME=Gemini 备份 +CHECK_GEMINI_TYPE=gemini +CHECK_GEMINI_KEY=ya29.xxxxx +CHECK_GEMINI_MODEL=gemini-1.5-flash +CHECK_GEMINI_ENDPOINT=https://generativelanguage.googleapis.com/v1beta + +CHECK_CLAUDE_NAME=Claude 回退 +CHECK_CLAUDE_TYPE=anthropic +CHECK_CLAUDE_KEY=sk-ant-xxxxx +CHECK_CLAUDE_MODEL=claude-3-5-sonnet-latest +``` -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +保存 `.env.local` 后刷新页面即可看到实时的 API 可用性结果。 diff --git a/app/api/dashboard/route.ts b/app/api/dashboard/route.ts new file mode 100644 index 0000000..859be59 --- /dev/null +++ b/app/api/dashboard/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; + +import { loadDashboardData } from "@/lib/dashboard-data"; + +export const revalidate = 0; +export const dynamic = "force-dynamic"; + +export async function GET() { + const data = await loadDashboardData({ refreshMode: "always" }); + return NextResponse.json(data); +} diff --git a/app/globals.css b/app/globals.css index a2dc41e..dc98be7 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,26 +1,122 @@ @import "tailwindcss"; +@import "tw-animate-css"; -:root { - --background: #ffffff; - --foreground: #171717; -} +@custom-variant dark (&:is(.dark *)); @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); } -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } } diff --git a/app/layout.tsx b/app/layout.tsx index f7fa87e..f6e655a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import "@/lib/poller"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -13,8 +14,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Check CX · AI 对话健康面板", + description: "实时检测 OpenAI / Gemini / Anthropic 对话接口的可用性与延迟", }; export default function RootLayout({ @@ -23,7 +24,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + diff --git a/app/page.tsx b/app/page.tsx index 295f8fd..58b6284 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,64 +1,16 @@ -import Image from "next/image"; +import { DashboardView } from "@/components/dashboard-view"; +import { loadDashboardData } from "@/lib/dashboard-data"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +export default async function Home() { + const data = await loadDashboardData({ refreshMode: "missing" }); -export default function Home() { return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- +
+
+
); diff --git a/components.json b/components.json new file mode 100644 index 0000000..b7b9791 --- /dev/null +++ b/components.json @@ -0,0 +1,22 @@ +{ + "$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": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/components/dashboard-view.tsx b/components/dashboard-view.tsx new file mode 100644 index 0000000..a529931 --- /dev/null +++ b/components/dashboard-view.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +import { ProviderIcon } from "@/components/provider-icon"; +import { StatusTimeline } from "@/components/status-timeline"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import type { DashboardData } from "@/lib/dashboard-data"; +import { PROVIDER_LABEL, STATUS_META } from "@/lib/status"; + +interface DashboardViewProps { + initialData: DashboardData; +} + +export function DashboardView({ initialData }: DashboardViewProps) { + const [data, setData] = useState(initialData); + const [isRefreshing, setIsRefreshing] = useState(false); + const lockRef = useRef(false); + + const refresh = useCallback(async () => { + if (lockRef.current) { + return; + } + lockRef.current = true; + setIsRefreshing(true); + try { + const response = await fetch("/api/dashboard", { cache: "no-store" }); + if (!response.ok) { + throw new Error("刷新数据失败"); + } + const next = (await response.json()) as DashboardData; + setData(next); + } catch (error) { + console.error("[check-cx] 自动刷新失败", error); + } finally { + setIsRefreshing(false); + lockRef.current = false; + } + }, []); + + useEffect(() => { + setData(initialData); + }, [initialData]); + + useEffect(() => { + if (!data.pollIntervalMs || data.pollIntervalMs <= 0) { + return; + } + const timer = window.setInterval(() => { + refresh().catch(() => undefined); + }, data.pollIntervalMs); + return () => window.clearInterval(timer); + }, [data.pollIntervalMs, refresh]); + + const { providerTimelines, total, lastUpdated, pollIntervalLabel } = data; + + return ( + <> +
+
+

+ + + + + 实时检查多模型可用性 +

+

+ AI 对话健康面板 +

+ {lastUpdated ? ( +

+ 最近更新:{lastUpdated} · 数据每 {pollIntervalLabel} 自动刷新{" "} + {isRefreshing && (同步中…)} +

+ ) : ( +

+ 尚未找到任何检测配置,请先在 .env 中定义 CHECK_GROUPS。 +

+ )} +
+
+ + {total === 0 ? ( + + + + 尚未配置任何检查目标 + + + 在项目根目录的 .env 中配置 CHECK_GROUPS 以及每组的 + TYPE/KEY/MODEL/ENDPOINT,随后刷新此页面即可看到实时状态。 + + + +
+CHECK_GROUPS=main,backup,gemini,claude
+CHECK_MAIN_NAME=主力 OpenAI
+CHECK_MAIN_TYPE=openai
+CHECK_MAIN_KEY=sk-xxxx
+CHECK_MAIN_MODEL=gpt-4o-mini
+CHECK_MAIN_ENDPOINT=https://api.openai.com/v1/chat/completions
+
+CHECK_BACKUP_NAME=备用 OpenAI
+CHECK_BACKUP_TYPE=openai
+CHECK_BACKUP_KEY=sk-xxxx
+CHECK_BACKUP_MODEL=gpt-4o-mini
+CHECK_BACKUP_ENDPOINT=https://api.openai.com/v1/chat/completions
+
+CHECK_GEMINI_NAME=Gemini 备份
+CHECK_GEMINI_TYPE=gemini
+CHECK_GEMINI_KEY=xxx
+CHECK_GEMINI_MODEL=gemini-1.5-flash
+CHECK_GEMINI_ENDPOINT=https://generativelanguage.googleapis.com/v1beta
+            
+
+
+ ) : ( +
+ {providerTimelines.map(({ id, latest, items }) => { + const preset = STATUS_META[latest.status]; + return ( + +
+
+
+ +
+
+ +
+
+ {latest.name} +

+ {PROVIDER_LABEL[latest.type]} ·{" "} + {latest.model} +

+
+ + {preset.label} + +
+ +
+
+

+ 最近检查 +

+

{latest.formattedTime}

+
+
+

+ 延迟 +

+

+ {latest.latencyMs ? `${latest.latencyMs} ms` : "—"} +

+
+
+

+ 近 1 小时 +

+

{items.length} 次检测

+
+
+
+ + + + + ); + })} +
+ )} + + ); +} diff --git a/components/provider-icon.tsx b/components/provider-icon.tsx new file mode 100644 index 0000000..0551019 --- /dev/null +++ b/components/provider-icon.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { Anthropic, Gemini, OpenAI } from "@lobehub/icons"; + +import type { ProviderType } from "@/lib/checks"; +import { cn } from "@/lib/utils"; + +const ICON_MAP: Record> = { + openai: OpenAI, + gemini: Gemini, + anthropic: Anthropic, +}; + +interface ProviderIconProps { + type: ProviderType; + className?: string; + size?: number; +} + +export function ProviderIcon({ type, className, size = 18 }: ProviderIconProps) { + const Icon = ICON_MAP[type]; + if (!Icon) return null; + return ; +} diff --git a/components/status-timeline.tsx b/components/status-timeline.tsx new file mode 100644 index 0000000..8c0b464 --- /dev/null +++ b/components/status-timeline.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { ProviderIcon } from "@/components/provider-icon"; +import type { CheckResult } from "@/lib/checks"; +import { PROVIDER_LABEL, STATUS_META } from "@/lib/status"; +import { cn } from "@/lib/utils"; + +interface TimelineItem extends CheckResult { + formattedTime: string; +} + +interface StatusTimelineProps { + items: TimelineItem[]; +} + +const SEGMENT_LIMIT = 60; + +export function StatusTimeline({ items }: StatusTimelineProps) { + if (items.length === 0) { + return ( +
+ 暂无检测记录。请在 .env 中添加 CHECK_GROUPS 并刷新页面。 +
+ ); + } + + const segments = Array.from({ length: SEGMENT_LIMIT }, (_, index) => + items[index] ?? null + ); + + return ( +
+
+
+
+
+ {segments.map((segment, index) => { + const preset = segment ? STATUS_META[segment.status] : undefined; + return ( +
+ {segment && ( +
+

+ {preset?.label} · {segment.formattedTime} +

+

+ + {PROVIDER_LABEL[segment.type]} + {segment.model} +

+

+ 延迟 {segment.latencyMs ? `${segment.latencyMs} ms` : "—"} +

+

+ {segment.message} +

+
+ )} +
+ ); + })} +
+
+
+
+ +60 min + 最新 +
+
+ ); +} diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..11d365e --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring/60 focus:ring-offset-2", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground", + secondary: "border-transparent bg-secondary text-secondary-foreground", + outline: "text-foreground", + success: "border-transparent bg-emerald-100 text-emerald-700", + warning: "border-transparent bg-amber-100 text-amber-700", + danger: "border-transparent bg-rose-100 text-rose-700", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +Badge.displayName = "Badge"; + +export { Badge, badgeVariants }; diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..9d4b203 --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,48 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + outline: + "border border-input bg-background text-foreground hover:bg-muted hover:text-foreground", + ghost: "hover:bg-muted hover:text-foreground", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps {} + +const Button = React.forwardRef( + ({ className, variant, size, ...props }, ref) => { + return ( +