img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(5)] *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/components/ui/command.tsx b/components/ui/command.tsx
new file mode 100644
index 0000000..3195ebd
--- /dev/null
+++ b/components/ui/command.tsx
@@ -0,0 +1,193 @@
+"use client"
+
+import * as React from "react"
+import { Command as CommandPrimitive } from "cmdk"
+import { cn } from "cn"
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ InputGroup,
+ InputGroupAddon,
+} from "@/components/ui/input-group"
+import { SearchIcon, CheckIcon } from "lucide-react"
+
+function Command({
+ className,
+ ...props
+}: React.ComponentProps
) {
+ return (
+
+ )
+}
+
+function CommandDialog({
+ title = "Command Palette",
+ description = "Search for a command to run...",
+ children,
+ className,
+ showCloseButton = false,
+ ...props
+}: Omit, "children"> & {
+ title?: string
+ description?: string
+ className?: string
+ showCloseButton?: boolean
+ children: React.ReactNode
+}) {
+ return (
+
+
+ {title}
+ {description}
+
+
+ {children}
+
+
+ )
+}
+
+function CommandInput({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
+function CommandList({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandEmpty({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandGroup({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CommandItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function CommandShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+export {
+ Command,
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandShortcut,
+ CommandSeparator,
+}
diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx
new file mode 100644
index 0000000..c83cbf7
--- /dev/null
+++ b/components/ui/dialog.tsx
@@ -0,0 +1,160 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
+import { cn } from "cn"
+
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Dialog({ ...props }: DialogPrimitive.Root.Props) {
+ return
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+ return
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+ return
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: DialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: DialogPrimitive.Popup.Props & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+ }>
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: DialogPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/components/ui/field.tsx b/components/ui/field.tsx
new file mode 100644
index 0000000..fd223c8
--- /dev/null
+++ b/components/ui/field.tsx
@@ -0,0 +1,238 @@
+"use client"
+
+import { useMemo } from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { cn } from "cn"
+
+import { Label } from "@/components/ui/label"
+import { Separator } from "@/components/ui/separator"
+
+function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
+ return (
+ [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 (
+
+ )
+}
+
+function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+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 has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ responsive:
+ "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @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) {
+ return (
+
+ )
+}
+
+function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function FieldLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+ [data-slot=field]]:rounded-none has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[:focus-visible]:ring-2 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/30 *:data-[slot=field]:p-4 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
+ "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ children?: React.ReactNode
+}) {
+ return (
+
+
+ {children && (
+
+ {children}
+
+ )}
+
+ )
+}
+
+function FieldError({
+ className,
+ children,
+ errors,
+ ...props
+}: React.ComponentProps<"div"> & {
+ errors?: Array<{ message?: string } | undefined>
+}) {
+ const content = useMemo(() => {
+ if (children) {
+ return children
+ }
+
+ if (!errors?.length) {
+ return null
+ }
+
+ const uniqueErrors = [
+ ...new Map(errors.map((error) => [error?.message, error])).values(),
+ ]
+
+ if (uniqueErrors?.length == 1) {
+ return uniqueErrors[0]?.message
+ }
+
+ return (
+
+ {uniqueErrors.map(
+ (error, index) =>
+ error?.message && {error.message}
+ )}
+
+ )
+ }, [children, errors])
+
+ if (!content) {
+ return null
+ }
+
+ return (
+
+ {content}
+
+ )
+}
+
+export {
+ Field,
+ FieldLabel,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLegend,
+ FieldSeparator,
+ FieldSet,
+ FieldContent,
+ FieldTitle,
+}
diff --git a/components/ui/input-group.tsx b/components/ui/input-group.tsx
new file mode 100644
index 0000000..2f43a63
--- /dev/null
+++ b/components/ui/input-group.tsx
@@ -0,0 +1,155 @@
+"use client"
+
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { cn } from "cn"
+
+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 (
+ [data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:has-[[data-slot][aria-invalid=true]]:border-b-destructive/50 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+const inputGroupAddonVariants = cva(
+ "flex h-auto cursor-text items-center justify-center gap-2 py-2 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 **:data-[slot=kbd]:rounded-none **:data-[slot=kbd]:bg-muted-foreground/10 **:data-[slot=kbd]:px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
+ {
+ variants: {
+ align: {
+ "inline-start": "order-first",
+ "inline-end": "order-last",
+ "block-start":
+ "order-first w-full justify-start pt-3 group-has-[>input]/input-group:pt-3.5 [.border-b]:pb-3.5",
+ "block-end":
+ "order-last w-full justify-start pb-3 group-has-[>input]/input-group:pb-3.5 [.border-t]:pt-3.5",
+ },
+ },
+ defaultVariants: {
+ align: "inline-start",
+ },
+ }
+)
+
+function InputGroupAddon({
+ className,
+ align = "inline-start",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps
) {
+ return (
+ {
+ if ((e.target as HTMLElement).closest("button")) {
+ return
+ }
+ e.currentTarget.parentElement?.querySelector("input")?.focus()
+ }}
+ {...props}
+ />
+ )
+}
+
+const inputGroupButtonVariants = cva(
+ "flex items-center gap-2 rounded-none text-sm shadow-none",
+ {
+ variants: {
+ size: {
+ xs: "h-6 gap-1 rounded-none px-1.5 text-xs [&>svg:not([class*='size-'])]:size-3.5",
+ sm: "",
+ "icon-xs": "size-6 p-0 text-xs 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
, "size" | "type"> &
+ VariantProps & {
+ type?: "button" | "submit" | "reset"
+ }) {
+ return (
+
+ )
+}
+
+function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function InputGroupInput({
+ className,
+ ...props
+}: React.ComponentProps<"input">) {
+ return (
+ [data-align=inline-end]]/input-group:pr-2 group-has-[>[data-align=inline-start]]/input-group:pl-2 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function InputGroupTextarea({
+ className,
+ ...props
+}: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupText,
+ InputGroupInput,
+ InputGroupTextarea,
+}
diff --git a/components/ui/input.tsx b/components/ui/input.tsx
new file mode 100644
index 0000000..cef28b3
--- /dev/null
+++ b/components/ui/input.tsx
@@ -0,0 +1,19 @@
+import * as React from "react"
+import { Input as InputPrimitive } from "@base-ui/react/input"
+import { cn } from "cn"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/components/ui/label.tsx b/components/ui/label.tsx
new file mode 100644
index 0000000..d4e5bab
--- /dev/null
+++ b/components/ui/label.tsx
@@ -0,0 +1,19 @@
+"use client"
+
+import * as React from "react"
+import { cn } from "cn"
+
+function Label({ className, ...props }: React.ComponentProps<"label">) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/components/ui/popover.tsx b/components/ui/popover.tsx
new file mode 100644
index 0000000..d4db572
--- /dev/null
+++ b/components/ui/popover.tsx
@@ -0,0 +1,92 @@
+"use client"
+
+import * as React from "react"
+import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
+import { cn } from "cn"
+
+function Popover({ ...props }: PopoverPrimitive.Root.Props) {
+ return
+}
+
+function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
+ return
+}
+
+function PopoverContent({
+ className,
+ align = "center",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ ...props
+}: PopoverPrimitive.Popup.Props &
+ Pick<
+ PopoverPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ )
+}
+
+function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function PopoverDescription({
+ className,
+ ...props
+}: PopoverPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Popover,
+ PopoverContent,
+ PopoverDescription,
+ PopoverHeader,
+ PopoverTitle,
+ PopoverTrigger,
+}
diff --git a/components/ui/scroll-area.tsx b/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..d63a06b
--- /dev/null
+++ b/components/ui/scroll-area.tsx
@@ -0,0 +1,54 @@
+"use client"
+
+import * as React from "react"
+import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
+import { cn } from "cn"
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: ScrollAreaPrimitive.Root.Props) {
+ return (
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function ScrollBar({
+ className,
+ orientation = "vertical",
+ ...props
+}: ScrollAreaPrimitive.Scrollbar.Props) {
+ return (
+
+
+
+ )
+}
+
+export { ScrollArea, ScrollBar }
diff --git a/components/ui/select.tsx b/components/ui/select.tsx
new file mode 100644
index 0000000..8ad0bf4
--- /dev/null
+++ b/components/ui/select.tsx
@@ -0,0 +1,206 @@
+"use client"
+
+import * as React from "react"
+import { Select as SelectPrimitive } from "@base-ui/react/select"
+import { cn } from "cn"
+import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
+
+const Select = SelectPrimitive.Root
+
+function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
+ return (
+
+ )
+}
+
+function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
+ return (
+
+ )
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: SelectPrimitive.Trigger.Props & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+ }
+ />
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ side = "bottom",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ alignItemWithTrigger = true,
+ ...props
+}: SelectPrimitive.Popup.Props &
+ Pick<
+ SelectPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
+ >) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: SelectPrimitive.GroupLabel.Props) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: SelectPrimitive.Item.Props) {
+ return (
+
+
+ {children}
+
+
+ }
+ >
+
+
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: SelectPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx
new file mode 100644
index 0000000..d69c134
--- /dev/null
+++ b/components/ui/separator.tsx
@@ -0,0 +1,24 @@
+"use client"
+
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
+import { cn } from "cn"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ ...props
+}: SeparatorPrimitive.Props) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/components/ui/skeleton.tsx b/components/ui/skeleton.tsx
new file mode 100644
index 0000000..4ce74e5
--- /dev/null
+++ b/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "cn"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/components/ui/sonner.tsx b/components/ui/sonner.tsx
new file mode 100644
index 0000000..9280ee5
--- /dev/null
+++ b/components/ui/sonner.tsx
@@ -0,0 +1,49 @@
+"use client"
+
+import { useTheme } from "next-themes"
+import { Toaster as Sonner, type ToasterProps } from "sonner"
+import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme()
+
+ return (
+
+ ),
+ info: (
+
+ ),
+ warning: (
+
+ ),
+ error: (
+
+ ),
+ loading: (
+
+ ),
+ }}
+ style={
+ {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)",
+ } as React.CSSProperties
+ }
+ toastOptions={{
+ classNames: {
+ toast: "cn-toast",
+ },
+ }}
+ {...props}
+ />
+ )
+}
+
+export { Toaster }
diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx
new file mode 100644
index 0000000..56263ff
--- /dev/null
+++ b/components/ui/tabs.tsx
@@ -0,0 +1,81 @@
+"use client"
+
+import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
+import { cva, type VariantProps } from "class-variance-authority"
+import { cn } from "cn"
+
+function Tabs({
+ className,
+ orientation = "horizontal",
+ ...props
+}: TabsPrimitive.Root.Props) {
+ return (
+
+ )
+}
+
+const tabsListVariants = cva(
+ "group/tabs-list inline-flex w-fit items-center justify-center p-1 text-muted-foreground group-data-horizontal/tabs:h-10 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
+ {
+ variants: {
+ variant: {
+ default: "bg-muted",
+ line: "gap-1 bg-transparent",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function TabsList({
+ className,
+ variant = "default",
+ ...props
+}: TabsPrimitive.List.Props & VariantProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
+ return (
+
+ )
+}
+
+function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
diff --git a/components/ui/textarea.tsx b/components/ui/textarea.tsx
new file mode 100644
index 0000000..a03cdef
--- /dev/null
+++ b/components/ui/textarea.tsx
@@ -0,0 +1,17 @@
+import * as React from "react"
+import { cn } from "cn"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx
new file mode 100644
index 0000000..cb16bea
--- /dev/null
+++ b/components/ui/tooltip.tsx
@@ -0,0 +1,65 @@
+"use client"
+
+import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
+import { cn } from "cn"
+
+function TooltipProvider({
+ delay = 0,
+ ...props
+}: TooltipPrimitive.Provider.Props) {
+ return (
+
+ )
+}
+
+function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
+ return
+}
+
+function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
+ return
+}
+
+function TooltipContent({
+ className,
+ side = "top",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ children,
+ ...props
+}: TooltipPrimitive.Popup.Props &
+ Pick<
+ TooltipPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/db/client.ts b/db/client.ts
new file mode 100644
index 0000000..125500c
--- /dev/null
+++ b/db/client.ts
@@ -0,0 +1,18 @@
+import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
+import postgres from "postgres";
+import * as schema from "./schema";
+
+let database: PostgresJsDatabase | undefined;
+
+export function getDb() {
+ const url = process.env.DATABASE_URL;
+ if (!url) throw new Error("DATABASE_URL is required for database-backed operations.");
+ database ??= drizzle(postgres(url, {
+ max: Number(process.env.DATABASE_POOL_SIZE ?? 10),
+ prepare: false,
+ connection: {
+ application_name: "gcsim-translator-readonly",
+ },
+ }), { schema });
+ return database;
+}
diff --git a/db/schema.ts b/db/schema.ts
new file mode 100644
index 0000000..97420c1
--- /dev/null
+++ b/db/schema.ts
@@ -0,0 +1,13 @@
+import { integer, pgSchema, text, varchar } from "drizzle-orm/pg-core";
+
+export const catalogSchema = pgSchema("catalog");
+
+export const catalogCharacters = catalogSchema.table("character", {
+ key: varchar("key", { length: 64 }).primaryKey(), name: text("name").notNull(), element: varchar("element", { length: 48 }), rarity: integer("rarity"), weaponType: varchar("weapon_type", { length: 64 }), imageKey: text("image_key").notNull(),
+});
+export const catalogWeapons = catalogSchema.table("weapon", {
+ key: varchar("key", { length: 64 }).primaryKey(), name: text("name").notNull(), rarity: integer("rarity"), weaponType: varchar("weapon_type", { length: 64 }), imageKey: text("image_key").notNull(),
+});
+export const catalogArtifacts = catalogSchema.table("artifact", {
+ key: varchar("key", { length: 64 }).primaryKey(), name: text("name").notNull(), rarity: integer("rarity"), imageKey: text("image_key").notNull(),
+});
diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml
new file mode 100644
index 0000000..c73f7dd
--- /dev/null
+++ b/k8s/configmap.yaml
@@ -0,0 +1,11 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: gcsim-config
+data:
+ NODE_ENV: production
+ HOSTNAME: 0.0.0.0
+ PORT: "3000"
+ S3_PUBLIC_URL: https://buzz-cdn.astrxl.dev
+ DATABASE_POOL_SIZE: "10"
+ NEXT_DEPLOYMENT_ID: initial
diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml
new file mode 100644
index 0000000..47f2f2d
--- /dev/null
+++ b/k8s/deployment.yaml
@@ -0,0 +1,50 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: gcsim
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: gcsim
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: gcsim
+ spec:
+ containers:
+ - name: app
+ image: gcsim-image
+ ports:
+ - name: http
+ containerPort: 3000
+ envFrom:
+ - configMapRef:
+ name: gcsim-config
+ - secretRef:
+ name: gcsim-secrets
+ readinessProbe:
+ tcpSocket:
+ port: http
+ initialDelaySeconds: 5
+ livenessProbe:
+ tcpSocket:
+ port: http
+ initialDelaySeconds: 15
+ periodSeconds: 20
+ resources:
+ requests:
+ cpu: 100m
+ memory: 128Mi
+ limits:
+ cpu: "1"
+ memory: 512Mi
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop: [ALL]
+ runAsNonRoot: true
+ runAsUser: 1001
+ runAsGroup: 1001
+ seccompProfile:
+ type: RuntimeDefault
diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml
new file mode 100644
index 0000000..7e35652
--- /dev/null
+++ b/k8s/ingress.yaml
@@ -0,0 +1,16 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: gcsim
+spec:
+ rules:
+ - host: gcsim.gunshiz.top
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: gcsim
+ port:
+ name: http
diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml
new file mode 100644
index 0000000..c7466ab
--- /dev/null
+++ b/k8s/kustomization.yaml
@@ -0,0 +1,13 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: gcsim
+resources:
+ - namespace.yaml
+ - configmap.yaml
+ - deployment.yaml
+ - service.yaml
+ - ingress.yaml
+images:
+ - name: gcsim-image
+ newName: registry.neko-piranha.ts.net/astral/gcsim
+ newTag: latest
diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml
new file mode 100644
index 0000000..a13e72c
--- /dev/null
+++ b/k8s/namespace.yaml
@@ -0,0 +1,4 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: gcsim
diff --git a/k8s/service.yaml b/k8s/service.yaml
new file mode 100644
index 0000000..504245f
--- /dev/null
+++ b/k8s/service.yaml
@@ -0,0 +1,11 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: gcsim
+spec:
+ selector:
+ app.kubernetes.io/name: gcsim
+ ports:
+ - name: http
+ port: 80
+ targetPort: http
diff --git a/lib/catalog-rarity.test.ts b/lib/catalog-rarity.test.ts
new file mode 100644
index 0000000..8d042ee
--- /dev/null
+++ b/lib/catalog-rarity.test.ts
@@ -0,0 +1,11 @@
+import { describe, expect, it } from "vitest";
+import { rarityGradientClass } from "./catalog-rarity";
+
+describe("catalog rarity backgrounds", () => {
+ it("matches Buzz Sheet rarity gradients", () => {
+ expect(rarityGradientClass(3)).toBe("bg-[#4D7691] bg-[linear-gradient(90deg,rgba(77,118,145,1)_0%,rgba(71,135,165,1)_100%)]");
+ expect(rarityGradientClass(4)).toContain("rgba(109,106,156,1)");
+ expect(rarityGradientClass(5)).toContain("rgba(166,123,52,1)");
+ expect(rarityGradientClass(null)).toBeUndefined();
+ });
+});
diff --git a/lib/catalog-rarity.ts b/lib/catalog-rarity.ts
new file mode 100644
index 0000000..6b41a9b
--- /dev/null
+++ b/lib/catalog-rarity.ts
@@ -0,0 +1,11 @@
+const rarityGradientClasses: Record = {
+ 1: "bg-[linear-gradient(90deg,rgba(23,25,28,1)_0%,rgba(19,20,23,1)_100%)]",
+ 2: "bg-[linear-gradient(90deg,rgba(11,43,24,1)_0%,rgba(7,18,13,1)_100%)]",
+ 3: "bg-[#4D7691] bg-[linear-gradient(90deg,rgba(77,118,145,1)_0%,rgba(71,135,165,1)_100%)]",
+ 4: "bg-[#6D6A9C] bg-[linear-gradient(90deg,rgba(109,106,156,1)_0%,rgba(150,122,179,1)_100%)]",
+ 5: "bg-[#A67B34] bg-[linear-gradient(90deg,rgba(166,123,52,1)_0%,rgba(178,123,44,1)_100%)]",
+};
+
+export function rarityGradientClass(rarity: number | null | undefined): string | undefined {
+ return rarity ? rarityGradientClasses[rarity] : undefined;
+}
diff --git a/lib/catalog.ts b/lib/catalog.ts
new file mode 100644
index 0000000..444e73e
--- /dev/null
+++ b/lib/catalog.ts
@@ -0,0 +1,52 @@
+import "server-only";
+import { asc } from "drizzle-orm";
+import { getDb } from "@/db/client";
+import { catalogArtifacts, catalogCharacters, catalogWeapons } from "@/db/schema";
+import { gcsimKey } from "@/lib/translator/keys";
+import type { Catalog, CatalogItem } from "@/lib/translator/types";
+
+const fallback: Catalog = {
+ version: "fallback",
+ characters: [
+ { id: "10000002", key: "kamisatoayaka", name: "Kamisato Ayaka", imageUrl: "", rarity: 5, weaponType: "WEAPON_SWORD_ONE_HAND", element: "Cryo", supported: true },
+ { id: "10000032", key: "bennett", name: "Bennett", imageUrl: "", rarity: 4, weaponType: "WEAPON_SWORD_ONE_HAND", element: "Pyro", supported: true },
+ { id: "10000089", key: "furina", name: "Furina", imageUrl: "", rarity: 5, weaponType: "WEAPON_SWORD_ONE_HAND", element: "Hydro", supported: true },
+ { id: "10000047", key: "kaedeharakazuha", name: "Kaedehara Kazuha", imageUrl: "", rarity: 5, weaponType: "WEAPON_SWORD_ONE_HAND", element: "Anemo", supported: true },
+ ],
+ weapons: [
+ { id: "11401", key: "favoniussword", name: "Favonius Sword", imageUrl: "", rarity: 4, weaponType: "WEAPON_SWORD_ONE_HAND", supported: true },
+ { id: "11509", key: "mistsplitterreforged", name: "Mistsplitter Reforged", imageUrl: "", rarity: 5, weaponType: "WEAPON_SWORD_ONE_HAND", supported: true },
+ ],
+ artifacts: [
+ { id: "15007", key: "noblesseoblige", name: "Noblesse Oblige", imageUrl: "", rarity: 5, supported: true },
+ { id: "15020", key: "blizzardstrayer", name: "Blizzard Strayer", imageUrl: "", rarity: 5, supported: true },
+ { id: "15032", key: "goldentroupe", name: "Golden Troupe", imageUrl: "", rarity: 5, supported: true },
+ ],
+};
+
+function publicUrl(key: string) {
+ const base = (process.env.S3_PUBLIC_URL ?? "https://buzz-cdn.astrxl.dev").replace(/\/+$/u, "");
+ return `${base}/${key.split("/").map(encodeURIComponent).join("/")}`;
+}
+
+export async function getCatalog(): Promise {
+ if (!process.env.DATABASE_URL) return fallback;
+ try {
+ const db = getDb();
+ const [characters, weapons, artifacts] = await Promise.all([
+ db.select().from(catalogCharacters).orderBy(asc(catalogCharacters.name)),
+ db.select().from(catalogWeapons).orderBy(asc(catalogWeapons.name)),
+ db.select().from(catalogArtifacts).orderBy(asc(catalogArtifacts.name)),
+ ]);
+ if (!characters.length || !weapons.length || !artifacts.length) return fallback;
+ const map = (kind: "character" | "weapon" | "artifact", rows: typeof characters | typeof weapons | typeof artifacts): CatalogItem[] => rows.map((row) => {
+ const character = "element" in row ? row : null;
+ const key = gcsimKey(kind, row.name, character?.element, row.key);
+ return { id: row.key, key, name: row.name, imageUrl: publicUrl(row.imageKey), rarity: row.rarity, weaponType: "weaponType" in row ? row.weaponType : null, element: character?.element ?? null, supported: Boolean(key) };
+ });
+ return { version: "buzz-current", characters: map("character", characters), weapons: map("weapon", weapons), artifacts: map("artifact", artifacts) };
+ } catch (error) {
+ console.error("Catalog unavailable; using fallback catalog", error);
+ return fallback;
+ }
+}
diff --git a/lib/translator/generate.test.ts b/lib/translator/generate.test.ts
new file mode 100644
index 0000000..c516818
--- /dev/null
+++ b/lib/translator/generate.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+import { calculateSubstats, generateBuild, SUBSTAT_SCALARS } from "./generate";
+import type { Build } from "./types";
+
+const build: Build = {
+ id: "test", characterId: "10000032", characterKey: "bennett", characterName: "Bennett", characterImageUrl: "", level: 90, maxLevel: 90, constellation: 6, talents: [9, 12, 13], weaponId: "11401", weaponKey: "favoniussword", weaponName: "Favonius Sword", weaponLevel: 90, weaponMaxLevel: 90, refinement: 5, artifactMode: "4pc", artifactOneId: "15007", artifactOneKey: "noblesseoblige", artifactOneName: "Noblesse Oblige", artifactTwoId: "", artifactTwoKey: "", artifactTwoName: "", sands: "er", goblet: "pyro%", circlet: "cr", rolls: { hp: 2, atk: 1, def: 0, em: 2, er: 3, "hp%": 0, "atk%": 4, "def%": 1, cr: 5, cd: 6 },
+};
+
+describe("gcsim generator", () => {
+ it("uses current average roll scalars", () => {
+ expect(SUBSTAT_SCALARS["def%"]).toBe(0.062);
+ expect(calculateSubstats(build).hp).toBeCloseTo(507.88);
+ expect(calculateSubstats(build).cd).toBeCloseTo(0.3972);
+ });
+ it("generates a complete build block", () => {
+ expect(generateBuild(build)).toBe(`bennett char lvl=90/90 cons=6 talent=9,12,13;
+bennett add weapon="favoniussword" refine=5 lvl=90/90;
+bennett add set="noblesseoblige" count=4;
+bennett add stats hp=4780 atk=311 er=0.518 pyro%=0.466 cr=0.311; # main stats
+bennett add stats hp=507.88 atk=16.54 em=39.64 er=0.1653 atk%=0.1984 def%=0.062 cr=0.1655 cd=0.3972; # substats`);
+ });
+});
diff --git a/lib/translator/generate.ts b/lib/translator/generate.ts
new file mode 100644
index 0000000..5291892
--- /dev/null
+++ b/lib/translator/generate.ts
@@ -0,0 +1,46 @@
+import type { Build, MainStatKey, RollKey, Team } from "./types";
+
+export const SUBSTAT_SCALARS: Record = {
+ hp: 253.94, atk: 16.54, def: 19.68, em: 19.82,
+ er: 0.0551, "hp%": 0.0496, "atk%": 0.0496, "def%": 0.062,
+ cr: 0.0331, cd: 0.0662,
+};
+
+export const MAIN_STATS: Record = {
+ "hp%": 0.466, "atk%": 0.466, "def%": 0.583, em: 186.5, er: 0.518,
+ "pyro%": 0.466, "hydro%": 0.466, "cryo%": 0.466, "electro%": 0.466,
+ "anemo%": 0.466, "geo%": 0.466, "dendro%": 0.466, "phys%": 0.583,
+ cr: 0.311, cd: 0.622, heal: 0.359,
+};
+
+function number(value: number): string {
+ return value.toFixed(4).replace(/0+$/u, "").replace(/\.$/u, "");
+}
+
+function statsLine(character: string, stats: Record, comment: string): string {
+ const body = Object.entries(stats).filter(([, value]) => value !== 0).map(([key, value]) => `${key}=${number(value)}`).join(" ");
+ return `${character} add stats ${body}; # ${comment}`;
+}
+
+export function calculateSubstats(build: Build): Record {
+ return Object.fromEntries(Object.entries(build.rolls).map(([key, count]) => [key, count * SUBSTAT_SCALARS[key as RollKey]])) as Record;
+}
+
+export function generateBuild(build: Build): string {
+ const main: Record = { hp: 4780, atk: 311 };
+ for (const stat of [build.sands, build.goblet, build.circlet]) main[stat] = (main[stat] ?? 0) + MAIN_STATS[stat];
+ const sets = build.artifactMode === "4pc"
+ ? [`${build.characterKey} add set="${build.artifactOneKey}" count=4;`]
+ : [`${build.characterKey} add set="${build.artifactOneKey}" count=2;`, `${build.characterKey} add set="${build.artifactTwoKey}" count=2;`];
+ return [
+ `${build.characterKey} char lvl=${build.level}/${build.maxLevel} cons=${build.constellation} talent=${build.talents.join(",")};`,
+ `${build.characterKey} add weapon="${build.weaponKey}" refine=${build.refinement} lvl=${build.weaponLevel}/${build.weaponMaxLevel};`,
+ ...sets,
+ statsLine(build.characterKey, main, "main stats"),
+ statsLine(build.characterKey, calculateSubstats(build), "substats"),
+ ].join("\n");
+}
+
+export function generateTeam(team: Team): string {
+ return team.builds.map(generateBuild).join("\n\n");
+}
diff --git a/lib/translator/keys.test.ts b/lib/translator/keys.test.ts
new file mode 100644
index 0000000..d727e4d
--- /dev/null
+++ b/lib/translator/keys.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from "vitest";
+import { gcsimKey } from "./keys";
+
+describe("gcsim catalog keys", () => {
+ it("maps character display names with canonical overrides", () => {
+ expect(gcsimKey("character", "Kamisato Ayaka")).toBe("kamisatoayaka");
+ expect(gcsimKey("character", "Raiden Shogun")).toBe("raidenshogun");
+ expect(gcsimKey("character", "Dendro Traveler", "Dendro", "traveler_dendro")).toBe("aetherdendro");
+ expect(gcsimKey("character", "Cryo Traveler", "Cryo", "traveler_cryo")).toBe("aethercryo");
+ });
+ it("normalizes weapon and artifact names", () => {
+ expect(gcsimKey("weapon", "Mistsplitter Reforged")).toBe("mistsplitterreforged");
+ expect(gcsimKey("artifact", "Noblesse Oblige")).toBe("noblesseoblige");
+ });
+});
diff --git a/lib/translator/keys.ts b/lib/translator/keys.ts
new file mode 100644
index 0000000..f36dde6
--- /dev/null
+++ b/lib/translator/keys.ts
@@ -0,0 +1,10 @@
+export function normalizedKey(value: string): string {
+ return value.normalize("NFKD").replace(/[^a-zA-Z]/gu, "").toLowerCase();
+}
+
+export function gcsimKey(kind: "character" | "weapon" | "artifact", name: string, element?: string | null, id?: string): string {
+ if (kind === "character" && (id?.startsWith("traveler_") || name === "Traveler")) {
+ return `aether${element?.toLowerCase() ?? ""}`;
+ }
+ return normalizedKey(name);
+}
diff --git a/lib/translator/types.ts b/lib/translator/types.ts
new file mode 100644
index 0000000..1221a29
--- /dev/null
+++ b/lib/translator/types.ts
@@ -0,0 +1,84 @@
+import { z } from "zod";
+
+export const rollKeys = ["hp", "atk", "def", "em", "er", "hp%", "atk%", "def%", "cr", "cd"] as const;
+export type RollKey = (typeof rollKeys)[number];
+
+export const mainStatKeys = [
+ "hp%", "atk%", "def%", "em", "er", "pyro%", "hydro%", "cryo%",
+ "electro%", "anemo%", "geo%", "dendro%", "phys%", "cr", "cd", "heal",
+] as const;
+export type MainStatKey = (typeof mainStatKeys)[number];
+
+export const rollsSchema = z.record(z.enum(rollKeys), z.number().int().min(0).max(99));
+
+export const buildSchema = z.object({
+ id: z.string().max(80),
+ characterId: z.string().min(1).max(64),
+ characterKey: z.string().regex(/^[a-z]+$/),
+ characterName: z.string().min(1).max(120),
+ characterImageUrl: z.string().url().or(z.literal("")),
+ level: z.number().int().min(1).max(90),
+ maxLevel: z.number().int().min(20).max(90),
+ constellation: z.number().int().min(0).max(6),
+ talents: z.tuple([z.number().int().min(1).max(15), z.number().int().min(1).max(15), z.number().int().min(1).max(15)]),
+ weaponId: z.string().min(1).max(64),
+ weaponKey: z.string().regex(/^[a-z]+$/),
+ weaponName: z.string().min(1).max(160),
+ weaponLevel: z.number().int().min(1).max(90),
+ weaponMaxLevel: z.number().int().min(20).max(90),
+ refinement: z.number().int().min(1).max(5),
+ artifactMode: z.enum(["4pc", "2+2"]),
+ artifactOneId: z.string().min(1).max(64),
+ artifactOneKey: z.string().regex(/^[a-z]+$/),
+ artifactOneName: z.string().min(1).max(160),
+ artifactTwoId: z.string().max(64),
+ artifactTwoKey: z.string().regex(/^[a-z]*$/),
+ artifactTwoName: z.string().max(160),
+ sands: z.enum(["hp%", "atk%", "def%", "em", "er"]),
+ goblet: z.enum(["hp%", "atk%", "def%", "em", "pyro%", "hydro%", "cryo%", "electro%", "anemo%", "geo%", "dendro%", "phys%"]),
+ circlet: z.enum(["hp%", "atk%", "def%", "em", "cr", "cd", "heal"]),
+ rolls: rollsSchema,
+});
+
+export const teamSchema = z.object({
+ id: z.string().max(80),
+ name: z.string().trim().min(1).max(60),
+ builds: z.array(buildSchema).min(1).max(4),
+});
+
+export const translationSchema = z.object({
+ name: z.string().trim().min(1).max(100),
+ teams: z.array(teamSchema).min(1).max(12),
+}).superRefine((value, ctx) => {
+ value.teams.forEach((team, teamIndex) => {
+ const ids = new Set();
+ team.builds.forEach((build, buildIndex) => {
+ if (ids.has(build.characterId)) ctx.addIssue({ code: "custom", message: "A character can only appear once per team.", path: ["teams", teamIndex, "builds", buildIndex, "characterId"] });
+ ids.add(build.characterId);
+ if (build.level > build.maxLevel || build.weaponLevel > build.weaponMaxLevel) ctx.addIssue({ code: "custom", message: "Level cannot exceed ascension cap.", path: ["teams", teamIndex, "builds", buildIndex] });
+ if (build.artifactMode === "2+2" && !build.artifactTwoKey) ctx.addIssue({ code: "custom", message: "Choose a second artifact set.", path: ["teams", teamIndex, "builds", buildIndex, "artifactTwoKey"] });
+ });
+ });
+});
+
+export type Build = z.infer;
+export type Team = z.infer;
+export type Translation = z.infer;
+
+export interface CatalogItem {
+ id: string;
+ key: string;
+ name: string;
+ imageUrl: string;
+ rarity: number | null;
+ weaponType?: string | null;
+ element?: string | null;
+ supported: boolean;
+}
+
+export interface Catalog {
+ version: string;
+ characters: CatalogItem[];
+ weapons: CatalogItem[];
+ artifacts: CatalogItem[];
+}
diff --git a/lib/utils.ts b/lib/utils.ts
new file mode 100644
index 0000000..f815ded
--- /dev/null
+++ b/lib/utils.ts
@@ -0,0 +1 @@
+export { cn } from "cn"
diff --git a/next.config.ts b/next.config.ts
index e9ffa30..8722939 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,7 +1,16 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
- /* config options here */
+ output: "standalone",
+ allowedDevOrigins: ["astral"],
+ turbopack: {
+ root: process.cwd(),
+ },
+ images: {
+ remotePatterns: [
+ { protocol: "https", hostname: "buzz-cdn.astrxl.dev" },
+ ],
+ },
};
export default nextConfig;
diff --git a/package.json b/package.json
index 6295b3a..859232a 100644
--- a/package.json
+++ b/package.json
@@ -6,22 +6,38 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
- "lint": "eslint"
+ "lint": "eslint .",
+ "typecheck": "next typegen && tsc --noEmit",
+ "test": "vitest run"
},
"dependencies": {
+ "@base-ui/react": "^1.8.0",
+ "class-variance-authority": "^0.7.1",
+ "cmdk": "^1.1.1",
+ "cn": "^0.2.6",
+ "drizzle-orm": "^0.45.2",
+ "lucide-react": "^1.43.0",
"next": "16.3.4",
+ "next-themes": "^0.4.6",
+ "postgres": "^3.4.9",
"react": "19.2.8",
- "react-dom": "19.2.8"
+ "react-dom": "19.2.8",
+ "shadcn": "^4.21.0",
+ "sonner": "^2.0.8",
+ "tw-animate-css": "^1.4.0",
+ "zod": "^4.5.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@types/bun": "^1.4.2",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.3.4",
"tailwindcss": "^4",
- "typescript": "^5"
+ "typescript": "^5",
+ "vitest": "^5.0.0"
},
"packageManager": "bun@1.3.14",
"ignoreScripts": [