├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .prettierignore ├── .vscode └── settings.json ├── README.md ├── app ├── api │ └── og │ │ └── route.tsx ├── layout.tsx ├── page.tsx └── providers.tsx ├── components ├── calender-date-range-picker.tsx ├── data-card-placeholder.tsx ├── data-card.tsx ├── icons.tsx ├── latency-chart-card.tsx ├── latency-chart-placeholder.tsx ├── latency-chart-populated.tsx ├── latency-chart.tsx ├── layout.tsx ├── main-nav.tsx ├── model-cards.tsx ├── site-header.tsx ├── tailwind-indicator.tsx ├── theme-provider.tsx ├── theme-toggle.tsx └── ui │ ├── button.tsx │ ├── calendar.tsx │ ├── card.tsx │ ├── popover.tsx │ └── tabs.tsx ├── config └── site.ts ├── lib ├── fonts.ts └── utils.ts ├── next-env.d.ts ├── next.config.mjs ├── package.json ├── postcss.config.js ├── prettier.config.js ├── public ├── favicon.ico ├── gptstatus.png ├── next.svg ├── thirteen.svg └── vercel.svg ├── styles └── globals.css ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.tsbuildinfo ├── types └── nav.ts ├── workers ├── gpt-3.5-turbo │ ├── gpt-3.5-turbo-heartbeat.ts │ └── wrangler.toml ├── gpt-4 │ ├── gpt-4-heartbeat.ts │ └── wrangler.toml ├── text-davinci-003 │ ├── text-davinci-003-heartbeat.ts │ └── wrangler.toml └── utils.ts └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | .cache 3 | public 4 | node_modules 5 | *.esm.js 6 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/eslintrc", 3 | "root": true, 4 | "extends": [ 5 | "next/core-web-vitals", 6 | "prettier", 7 | "plugin:tailwindcss/recommended" 8 | ], 9 | "plugins": ["tailwindcss"], 10 | "rules": { 11 | "@next/next/no-html-link-for-pages": "off", 12 | "react/jsx-key": "off", 13 | "tailwindcss/no-custom-classname": "off" 14 | }, 15 | "settings": { 16 | "tailwindcss": { 17 | "callees": ["cn"], 18 | "config": "./tailwind.config.js" 19 | }, 20 | "next": { 21 | "rootDir": ["./"] 22 | } 23 | }, 24 | "overrides": [ 25 | { 26 | "files": ["*.ts", "*.tsx"], 27 | "parser": "@typescript-eslint/parser" 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .pnpm-debug.log* 25 | 26 | # local env files 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | 32 | # turbo 33 | .turbo 34 | 35 | .contentlayer 36 | .env 37 | 38 | # cloudflare 39 | .dev.vars 40 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | cache 2 | .cache 3 | package.json 4 | package-lock.json 5 | public 6 | CHANGELOG.md 7 | .yarn 8 | dist 9 | node_modules 10 | .next 11 | build 12 | .contentlayer -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "../../node_modules/.pnpm/typescript@4.9.5/node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # next-template 2 | 3 | A Next.js 13 template for building apps with Radix UI and Tailwind CSS. 4 | 5 | ## Usage 6 | 7 | ```bash 8 | npx create-next-app -e https://github.com/shadcn/next-template 9 | ``` 10 | 11 | ## Features 12 | 13 | - Radix UI Primitives 14 | - Tailwind CSS 15 | - Fonts with `next/font` 16 | - Icons from [Lucide](https://lucide.dev) 17 | - Dark mode with `next-themes` 18 | - Automatic import sorting with `@ianvs/prettier-plugin-sort-imports` 19 | - Tailwind CSS class sorting, merging and linting. 20 | 21 | ## License 22 | 23 | Licensed under the [MIT license](https://github.com/shadcn/ui/blob/main/LICENSE.md). 24 | -------------------------------------------------------------------------------- /app/api/og/route.tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from "@vercel/og" 2 | 3 | export const runtime = "edge" 4 | 5 | export async function GET(request: Request) { 6 | return new ImageResponse( 7 | ( 8 |
20 | Hello world! 21 |
22 | ), 23 | { 24 | width: 1200, 25 | height: 600, 26 | } 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "@/styles/globals.css" 2 | import { Metadata } from "next" 3 | import { Analytics } from "@vercel/analytics/react" 4 | 5 | import { siteConfig } from "@/config/site" 6 | import { fontSans } from "@/lib/fonts" 7 | import { cn } from "@/lib/utils" 8 | import { SiteHeader } from "@/components/site-header" 9 | import { TailwindIndicator } from "@/components/tailwind-indicator" 10 | import { ThemeProvider } from "@/components/theme-provider" 11 | 12 | import { CalendarSelectionProvider } from "./providers" 13 | 14 | export const metadata: Metadata = { 15 | title: { 16 | default: siteConfig.name, 17 | template: `%s - ${siteConfig.name}`, 18 | }, 19 | description: siteConfig.description, 20 | themeColor: [ 21 | { media: "(prefers-color-scheme: light)", color: "white" }, 22 | { media: "(prefers-color-scheme: dark)", color: "black" }, 23 | ], 24 | icons: { 25 | icon: "/favicon.ico", 26 | shortcut: "/favicon-16x16.png", 27 | apple: "/apple-touch-icon.png", 28 | }, 29 | openGraph: { 30 | type: "website", 31 | locale: "en_US", 32 | title: siteConfig.name, 33 | siteName: siteConfig.name, 34 | description: siteConfig.description, 35 | url: siteConfig.url, 36 | images: [ 37 | { 38 | url: `${siteConfig.url}/gptstatus.png`, 39 | width: 1200, 40 | height: 600, 41 | alt: siteConfig.name, 42 | }, 43 | ], 44 | }, 45 | twitter: { 46 | card: "summary_large_image", 47 | title: siteConfig.name, 48 | description: siteConfig.description, 49 | images: [ 50 | { 51 | url: `${siteConfig.url}/gptstatus.png`, 52 | width: 1200, 53 | height: 600, 54 | alt: siteConfig.name, 55 | }, 56 | ], 57 | creator: siteConfig.creator, 58 | }, 59 | } 60 | 61 | interface RootLayoutProps { 62 | children: React.ReactNode 63 | } 64 | 65 | export default function RootLayout({ children }: RootLayoutProps) { 66 | return ( 67 | <> 68 | 69 | 70 | 76 | 77 | 78 |
79 | 80 |
{children}
81 |
82 |
83 | 84 |
85 | 86 | 87 | 88 | 89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { CalendarDateRangePicker } from "@/components/calender-date-range-picker" 2 | import LatencyChartCard from "@/components/latency-chart-card" 3 | import ModelCards from "@/components/model-cards" 4 | 5 | export const revalidate = 150 6 | 7 | export default function IndexPage() { 8 | return ( 9 |
10 |
11 |

Dashboard

12 | 13 |
14 | 15 |
16 |
17 | 18 | 19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /app/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { createContext, useState } from "react" 4 | import { DateRange } from "react-day-picker" 5 | 6 | export const CalendarSelectionContext = createContext<{ 7 | date: DateRange | undefined 8 | setDate: React.Dispatch> 9 | }>({ 10 | date: undefined, 11 | setDate: () => {}, 12 | }) 13 | 14 | export function CalendarSelectionProvider({ 15 | children, 16 | }: { 17 | children: React.ReactNode 18 | }) { 19 | const [date, setDate] = useState(undefined) 20 | 21 | return ( 22 | 23 | {children} 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /components/calender-date-range-picker.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { useContext } from "react" 5 | import { format } from "date-fns" 6 | import { Calendar as CalendarIcon } from "lucide-react" 7 | 8 | import { cn } from "@/lib/utils" 9 | import { Button } from "@/components/ui/button" 10 | import { Calendar } from "@/components/ui/calendar" 11 | import { 12 | Popover, 13 | PopoverContent, 14 | PopoverTrigger, 15 | } from "@/components/ui/popover" 16 | import { CalendarSelectionContext } from "@/app/providers" 17 | 18 | export function CalendarDateRangePicker({ 19 | className, 20 | }: React.HTMLAttributes) { 21 | const { date, setDate } = useContext(CalendarSelectionContext) 22 | 23 | return ( 24 |
25 | 26 | 27 | 50 | 51 | 52 | 60 | 61 | 62 |
63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /components/data-card-placeholder.tsx: -------------------------------------------------------------------------------- 1 | import { Timer } from "lucide-react" 2 | 3 | import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" 4 | 5 | type Props = { 6 | title: string 7 | } 8 | 9 | export default function DataCardPlaceolder({ title }: Props) { 10 | return ( 11 | 12 | 13 | {title} 14 | 15 | 16 | 17 |
42s
18 | {/*

19 | +20.1% from last month 20 |

*/} 21 |
22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /components/data-card.tsx: -------------------------------------------------------------------------------- 1 | import { sql } from "@vercel/postgres" 2 | import { LucideIcon, Timer } from "lucide-react" 3 | 4 | import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" 5 | 6 | type Props = { 7 | title: string 8 | subtitle?: string 9 | model: string 10 | datapoint: string 11 | modifier?: (datapoint: string) => string | undefined 12 | unit: string | undefined 13 | Icon: LucideIcon 14 | } 15 | 16 | export default async function DataCard({ 17 | title, 18 | subtitle, 19 | model, 20 | datapoint, 21 | modifier, 22 | unit, 23 | Icon, 24 | }: Props) { 25 | let data 26 | try { 27 | // Fetch the latest response time for the specific model 28 | data = 29 | await sql`SELECT * FROM response_times WHERE model=${model} ORDER BY id DESC LIMIT 1` 30 | } catch (e) { 31 | console.log(e) 32 | throw e 33 | } 34 | 35 | const { rows } = data 36 | 37 | return ( 38 | 39 | 40 |
41 | {title} 42 | 43 | {subtitle ? subtitle : ""} 44 | 45 |
46 | 47 |
48 | 49 |
50 | {rows[0] 51 | ? modifier 52 | ? `${modifier(rows[0][datapoint])}${unit ? unit : ""}` 53 | : `${rows[0][datapoint]}${unit ? unit : ""}` 54 | : "N/A"} 55 |
56 | {/*

57 | +20.1% from last month 58 |

*/} 59 |
60 |
61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /components/icons.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | LucideProps, 3 | Moon, 4 | SunMedium, 5 | Twitter, 6 | type Icon as LucideIcon, 7 | } from "lucide-react" 8 | 9 | export type Icon = LucideIcon 10 | 11 | export const Icons = { 12 | sun: SunMedium, 13 | moon: Moon, 14 | twitter: Twitter, 15 | logo: (props: LucideProps) => ( 16 | 17 | 21 | 22 | ), 23 | gitHub: (props: LucideProps) => ( 24 | 25 | 29 | 30 | ), 31 | } 32 | -------------------------------------------------------------------------------- /components/latency-chart-card.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react" 2 | 3 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" 4 | import LatencyChart from "@/components/latency-chart" 5 | import LatencyChartPlaceholder from "@/components/latency-chart-placeholder" 6 | 7 | export default function LatencyChartCard() { 8 | return ( 9 | 10 | 11 | Overview 12 | 13 | 14 | }> 15 | {/* @ts-expect-error Async Server Component */} 16 | 17 | 18 | 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /components/latency-chart-placeholder.tsx: -------------------------------------------------------------------------------- 1 | export default function LatencyChartPlaceholder() { 2 | return
Loading ...
3 | } 4 | -------------------------------------------------------------------------------- /components/latency-chart-populated.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useContext } from "react" 4 | import { format } from "date-fns" 5 | import { 6 | CartesianGrid, 7 | Line, 8 | LineChart, 9 | ResponsiveContainer, 10 | Tooltip, 11 | XAxis, 12 | YAxis, 13 | } from "recharts" 14 | 15 | import { CalendarSelectionContext } from "@/app/providers" 16 | 17 | const modelColors: Record = { 18 | "text-davinci-003": "#0000FF", // bright blue 19 | "gpt-3.5-turbo": "#008000", // dark green 20 | "gpt-4": "#FF8C00", // dark orange 21 | } 22 | 23 | type Props = { 24 | data: Record[] 25 | } 26 | 27 | export default function LatencyChartPopulated({ data }: Props) { 28 | const { date, setDate } = useContext(CalendarSelectionContext) 29 | 30 | let displayedData 31 | if (date) { 32 | const { from, to } = date 33 | 34 | if (from && to) { 35 | displayedData = data.filter((item) => { 36 | const itemDate = new Date(item.date) 37 | return itemDate >= from && itemDate <= to 38 | }) 39 | } else if (from) { 40 | displayedData = data.filter((item) => { 41 | const itemDate = new Date(item.date) 42 | return itemDate >= from 43 | }) 44 | } else if (to) { 45 | displayedData = data.filter((item) => { 46 | const itemDate = new Date(item.date) 47 | return itemDate <= to 48 | }) 49 | } 50 | } else { 51 | displayedData = data.slice(-720) // 24 hours of data 52 | } 53 | 54 | if (displayedData == undefined || displayedData.length === 0) { 55 | return ( 56 |
57 |

No data to display

58 |
59 | ) 60 | } 61 | 62 | const uniqueModels = Array.from( 63 | new Set( 64 | displayedData 65 | .flatMap((item) => Object.keys(item)) 66 | .filter((key) => key !== "date") 67 | ) 68 | ) 69 | 70 | const CustomTooltip = ({ active, payload, label }: any) => { 71 | if (active && payload && payload.length) { 72 | // Sort the payload array in descending order based on the 'value' property 73 | const sortedPayload = [...payload].sort( 74 | (a: any, b: any) => b.value - a.value 75 | ) 76 | 77 | return ( 78 |
86 |

{`Date: ${format( 87 | new Date(label), 88 | "MMM dd HH:mm" 89 | )}`}

90 | {sortedPayload.map((entry: any) => ( 91 |

96 | {`${entry.dataKey}: ${entry.value} s`} 97 |

98 | ))} 99 |
100 | ) 101 | } 102 | 103 | return null 104 | } 105 | 106 | return ( 107 | 108 | 113 | 114 | {uniqueModels.map((model) => ( 115 | 122 | ))} 123 | format(new Date(tickItem), "HH:mm")} 126 | label={{ 127 | value: "Time", 128 | position: "insideBottom", 129 | offset: -8, 130 | style: { fontSize: "12px" }, 131 | }} 132 | /> 133 | 136 | } /> 137 | 138 | 139 | ) 140 | } 141 | -------------------------------------------------------------------------------- /components/latency-chart.tsx: -------------------------------------------------------------------------------- 1 | import { sql } from "@vercel/postgres" 2 | 3 | import LatencyLineChart from "./latency-chart-populated" 4 | 5 | export default async function LatencyChart() { 6 | let data 7 | try { 8 | data = await sql`SELECT * FROM response_times` 9 | } catch (e) { 10 | console.log(e) 11 | throw e 12 | } 13 | 14 | const { rows } = data 15 | 16 | const transformedData = rows.reduce[]>( 17 | (acc, row) => { 18 | const existingEntry = acc.find( 19 | (entry: Record) => 20 | entry.date === String(row.date) 21 | ) 22 | if (existingEntry) { 23 | existingEntry[row.model] = Number(row.duration / 1000) 24 | } else { 25 | acc.push({ 26 | date: String(row.date), 27 | [row.model]: Number(row.duration / 1000), 28 | }) 29 | } 30 | return acc 31 | }, 32 | [] 33 | ) 34 | 35 | return 36 | } 37 | -------------------------------------------------------------------------------- /components/layout.tsx: -------------------------------------------------------------------------------- 1 | import { SiteHeader } from "@/components/site-header" 2 | 3 | interface LayoutProps { 4 | children: React.ReactNode 5 | } 6 | 7 | export function Layout({ children }: LayoutProps) { 8 | return ( 9 | <> 10 | 11 |
{children}
12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /components/main-nav.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import Link from "next/link" 3 | 4 | import { NavItem } from "@/types/nav" 5 | import { siteConfig } from "@/config/site" 6 | import { cn } from "@/lib/utils" 7 | import { Icons } from "@/components/icons" 8 | 9 | interface MainNavProps { 10 | items?: NavItem[] 11 | } 12 | 13 | export function MainNav({ items }: MainNavProps) { 14 | return ( 15 |
16 | 17 | 18 | 19 | {siteConfig.name} 20 | 21 | 22 | {items?.length ? ( 23 | 40 | ) : null} 41 |
42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /components/model-cards.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react" 2 | import { ArrowUpDown, Gauge, Timer } from "lucide-react" 3 | 4 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" 5 | import DataCard from "@/components/data-card" 6 | import DataCardPlaceholder from "@/components/data-card-placeholder" 7 | 8 | const models = [ 9 | { 10 | name: "GPT 4", 11 | value: "gpt-4", 12 | }, 13 | { 14 | name: "GPT 3.5 Turbo", 15 | value: "gpt-3.5-turbo", 16 | }, 17 | { 18 | name: "Text Davinci 003", 19 | value: "text-davinci-003", 20 | }, 21 | { 22 | name: "Palm", 23 | value: "palm", 24 | disabled: true, 25 | }, 26 | ] 27 | 28 | const cards = [ 29 | { 30 | title: "Response Time", 31 | subtitle: "256 token test", 32 | datapoint: "duration", 33 | modifier: (datapoint: string) => `${Number(datapoint) / 1000}`, 34 | unit: "s", 35 | Icon: Timer, 36 | }, 37 | { 38 | title: "Time To First Byte", 39 | datapoint: "ttfb", 40 | unit: "ms", 41 | Icon: ArrowUpDown, 42 | }, 43 | { 44 | title: "Tokens Per Second", 45 | datapoint: "tps", 46 | Icon: Gauge, 47 | }, 48 | ] 49 | 50 | export default function ModelCards() { 51 | return ( 52 | 53 | 54 | {models.map( 55 | (model: { name: string; value: string; disabled?: boolean }) => { 56 | return ( 57 | 58 | {model.name} 59 | 60 | ) 61 | } 62 | )} 63 | 64 | {models.map((model: { name: string; value: string }) => { 65 | return ( 66 | 67 |
68 | {cards.map((card) => ( 69 | }> 70 | {/* @ts-expect-error Async Server Component */} 71 | 80 | 81 | ))} 82 |
83 |
84 | ) 85 | })} 86 |
87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /components/site-header.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { siteConfig } from "@/config/site" 4 | import { buttonVariants } from "@/components/ui/button" 5 | import { Icons } from "@/components/icons" 6 | import { MainNav } from "@/components/main-nav" 7 | import { ThemeToggle } from "@/components/theme-toggle" 8 | 9 | export function SiteHeader() { 10 | return ( 11 |
12 |
13 | 14 |
15 | 48 |
49 |
50 |
51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /components/tailwind-indicator.tsx: -------------------------------------------------------------------------------- 1 | export function TailwindIndicator() { 2 | if (process.env.NODE_ENV === "production") return null 3 | 4 | return ( 5 |
6 |
xs
7 |
8 | sm 9 |
10 |
md
11 |
lg
12 |
xl
13 |
2xl
14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ThemeProvider as NextThemesProvider } from "next-themes" 5 | import { ThemeProviderProps } from "next-themes/dist/types" 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children} 9 | } 10 | -------------------------------------------------------------------------------- /components/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { useTheme } from "next-themes" 5 | 6 | import { Button } from "@/components/ui/button" 7 | import { Icons } from "@/components/icons" 8 | 9 | export function ThemeToggle() { 10 | const { setTheme, theme } = useTheme() 11 | 12 | return ( 13 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { VariantProps, cva } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const buttonVariants = cva( 7 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 12 | destructive: 13 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 14 | outline: 15 | "border border-input hover:bg-accent hover:text-accent-foreground", 16 | secondary: 17 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 18 | ghost: "hover:bg-accent hover:text-accent-foreground", 19 | link: "underline-offset-4 hover:underline text-primary", 20 | }, 21 | size: { 22 | default: "h-10 py-2 px-4", 23 | sm: "h-9 px-3 rounded-md", 24 | lg: "h-11 px-8 rounded-md", 25 | }, 26 | }, 27 | defaultVariants: { 28 | variant: "default", 29 | size: "default", 30 | }, 31 | } 32 | ) 33 | 34 | export interface ButtonProps 35 | extends React.ButtonHTMLAttributes, 36 | VariantProps {} 37 | 38 | const Button = React.forwardRef( 39 | ({ className, variant, size, ...props }, ref) => { 40 | return ( 41 |