img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ 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/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..4fcd847
--- /dev/null
+++ b/src/components/ui/checkbox.tsx
@@ -0,0 +1,29 @@
+"use client"
+
+import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
+
+import { cn } from "@/lib/utils"
+import { CheckIcon } from "lucide-react"
+
+function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
+ return (
+
+
+
+
+
+ )
+}
+
+export { Checkbox }
diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx
new file mode 100644
index 0000000..37fb2d9
--- /dev/null
+++ b/src/components/ui/command.tsx
@@ -0,0 +1,196 @@
+"use client"
+
+import * as React from "react"
+import { Command as CommandPrimitive } from "cmdk"
+
+import { cn } from "@/lib/utils"
+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 (
+
+ )
+}
+
+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/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..807e1fa
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -0,0 +1,157 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+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/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..a126ca7
--- /dev/null
+++ b/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,271 @@
+"use client"
+
+import * as React from "react"
+import { Menu as MenuPrimitive } from "@base-ui/react/menu"
+
+import { cn } from "@/lib/utils"
+import { ChevronRightIcon, CheckIcon } from "lucide-react"
+
+function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
+ return
+}
+
+function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
+ return
+}
+
+function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
+ return
+}
+
+function DropdownMenuContent({
+ align = "start",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ className,
+ ...props
+}: MenuPrimitive.Popup.Props &
+ Pick<
+ MenuPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ )
+}
+
+function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
+ return
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: MenuPrimitive.GroupLabel.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: MenuPrimitive.Item.Props & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ align = "start",
+ alignOffset = -3,
+ side = "right",
+ sideOffset = 0,
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: MenuPrimitive.CheckboxItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: MenuPrimitive.RadioItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: MenuPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/src/components/ui/input-group.tsx b/src/components/ui/input-group.tsx
new file mode 100644
index 0000000..da8f1dd
--- /dev/null
+++ b/src/components/ui/input-group.tsx
@@ -0,0 +1,158 @@
+"use client"
+
+import * as React from "react"
+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 (
+ [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:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+const inputGroupAddonVariants = cva(
+ "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ align: {
+ "inline-start":
+ "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
+ "inline-end":
+ "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
+ "block-start":
+ "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
+ "block-end":
+ "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
+ },
+ },
+ 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 text-sm shadow-none",
+ {
+ variants: {
+ size: {
+ xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
+ sm: "",
+ "icon-xs":
+ "size-6 rounded-[calc(var(--radius)-3px)] 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
, "size" | "type"> &
+ VariantProps & {
+ type?: "button" | "submit" | "reset"
+ }) {
+ return (
+
+ )
+}
+
+function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function InputGroupInput({
+ className,
+ ...props
+}: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+function InputGroupTextarea({
+ className,
+ ...props
+}: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupText,
+ InputGroupInput,
+ InputGroupTextarea,
+}
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
new file mode 100644
index 0000000..7d21bab
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,20 @@
+import * as React from "react"
+import { Input as InputPrimitive } from "@base-ui/react/input"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx
new file mode 100644
index 0000000..74da65c
--- /dev/null
+++ b/src/components/ui/label.tsx
@@ -0,0 +1,20 @@
+"use client"
+
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Label({ className, ...props }: React.ComponentProps<"label">) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx
new file mode 100644
index 0000000..0b73c6b
--- /dev/null
+++ b/src/components/ui/popover.tsx
@@ -0,0 +1,90 @@
+"use client"
+
+import * as React from "react"
+import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
+
+import { cn } from "@/lib/utils"
+
+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/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..84c1e9f
--- /dev/null
+++ b/src/components/ui/scroll-area.tsx
@@ -0,0 +1,55 @@
+"use client"
+
+import * as React from "react"
+import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
+
+import { cn } from "@/lib/utils"
+
+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/src/components/ui/select.tsx b/src/components/ui/select.tsx
new file mode 100644
index 0000000..e8021f5
--- /dev/null
+++ b/src/components/ui/select.tsx
@@ -0,0 +1,201 @@
+"use client"
+
+import * as React from "react"
+import { Select as SelectPrimitive } from "@base-ui/react/select"
+
+import { cn } from "@/lib/utils"
+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/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
new file mode 100644
index 0000000..6e1369e
--- /dev/null
+++ b/src/components/ui/separator.tsx
@@ -0,0 +1,25 @@
+"use client"
+
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ ...props
+}: SeparatorPrimitive.Props) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx
new file mode 100644
index 0000000..727cc4d
--- /dev/null
+++ b/src/components/ui/sheet.tsx
@@ -0,0 +1,135 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Sheet({ ...props }: SheetPrimitive.Root.Props) {
+ return
+}
+
+function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
+ return
+}
+
+function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
+ return
+}
+
+function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
+ return
+}
+
+function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function SheetContent({
+ className,
+ children,
+ side = "right",
+ showCloseButton = true,
+ ...props
+}: SheetPrimitive.Popup.Props & {
+ side?: "top" | "right" | "bottom" | "left"
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function SheetDescription({
+ className,
+ ...props
+}: SheetPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Sheet,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+}
diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..0118624
--- /dev/null
+++ b/src/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx
new file mode 100644
index 0000000..9b8b44b
--- /dev/null
+++ b/src/components/ui/switch.tsx
@@ -0,0 +1,32 @@
+"use client"
+
+import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
+
+import { cn } from "@/lib/utils"
+
+function Switch({
+ className,
+ size = "default",
+ ...props
+}: SwitchPrimitive.Root.Props & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+
+
+ )
+}
+
+export { Switch }
diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx
new file mode 100644
index 0000000..8dc13ae
--- /dev/null
+++ b/src/components/ui/table.tsx
@@ -0,0 +1,116 @@
+"use client"
+
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+ return (
+
+ )
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+ return (
+
+ )
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+ return (
+
+ )
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
+ return (
+ tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+ |
+ )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+ |
+ )
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ )
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..56c4288
--- /dev/null
+++ b/src/components/ui/tabs.tsx
@@ -0,0 +1,82 @@
+"use client"
+
+import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+function Tabs({
+ className,
+ orientation = "horizontal",
+ ...props
+}: TabsPrimitive.Root.Props) {
+ return (
+
+ )
+}
+
+const tabsListVariants = cva(
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
+ {
+ 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/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..04d27f7
--- /dev/null
+++ b/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..69e8a82
--- /dev/null
+++ b/src/components/ui/tooltip.tsx
@@ -0,0 +1,66 @@
+"use client"
+
+import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
+
+import { cn } from "@/lib/utils"
+
+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/src/lib/db.ts b/src/lib/db.ts
new file mode 100644
index 0000000..8c48fd5
--- /dev/null
+++ b/src/lib/db.ts
@@ -0,0 +1,19 @@
+import { PrismaClient } from "@/generated/prisma/client";
+import { PrismaPg } from "@prisma/adapter-pg";
+import pg from "pg";
+
+const globalForPrisma = globalThis as unknown as {
+ prisma: PrismaClient | undefined;
+};
+
+function createPrismaClient() {
+ const pool = new pg.Pool({
+ connectionString: process.env.DATABASE_URL,
+ });
+ const adapter = new PrismaPg(pool);
+ return new PrismaClient({ adapter });
+}
+
+export const prisma = globalForPrisma.prisma ?? createPrismaClient();
+
+if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
diff --git a/src/lib/minio.ts b/src/lib/minio.ts
new file mode 100644
index 0000000..add1a10
--- /dev/null
+++ b/src/lib/minio.ts
@@ -0,0 +1,46 @@
+import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
+import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
+
+const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || "192.168.68.105";
+const MINIO_PORT = parseInt(process.env.MINIO_PORT || "9000");
+const MINIO_ACCESS_KEY = process.env.MINIO_ACCESS_KEY || "minioadmin";
+const MINIO_SECRET_KEY = process.env.MINIO_SECRET_KEY || "";
+const MINIO_BUCKET = process.env.MINIO_BUCKET || "echos-ocr";
+
+export const s3 = new S3Client({
+ endpoint: `http://${MINIO_ENDPOINT}:${MINIO_PORT}`,
+ region: "us-east-1",
+ credentials: {
+ accessKeyId: MINIO_ACCESS_KEY,
+ secretAccessKey: MINIO_SECRET_KEY,
+ },
+ forcePathStyle: true,
+});
+
+export const BUCKET = MINIO_BUCKET;
+
+export async function uploadBuffer(key: string, buffer: Buffer, contentType: string): Promise {
+ await s3.send(
+ new PutObjectCommand({
+ Bucket: BUCKET,
+ Key: key,
+ Body: buffer,
+ ContentType: contentType,
+ })
+ );
+ return key;
+}
+
+export async function getPresignedUrl(key: string, expiresIn = 3600): Promise {
+ return getSignedUrl(
+ s3,
+ new GetObjectCommand({ Bucket: BUCKET, Key: key }),
+ { expiresIn }
+ );
+}
+
+export async function deleteObject(key: string): Promise {
+ await s3.send(
+ new DeleteObjectCommand({ Bucket: BUCKET, Key: key })
+ );
+}
diff --git a/src/lib/ocr.ts b/src/lib/ocr.ts
new file mode 100644
index 0000000..1e22255
--- /dev/null
+++ b/src/lib/ocr.ts
@@ -0,0 +1,172 @@
+import { prisma } from "./db";
+import { uploadBuffer } from "./minio";
+import { ocrImage } from "./ollama";
+import { pdfToImages, imageToBase64, processUploadedImage } from "./pdf";
+
+export async function processFile(
+ jobId: string,
+ fileName: string,
+ fileBuffer: Buffer,
+ isPdf: boolean
+): Promise {
+ const cardIds: string[] = [];
+
+ try {
+ await prisma.processingJob.update({
+ where: { id: jobId },
+ data: { status: "processing" },
+ });
+
+ let pageImages: { buffer: Buffer; page: number }[];
+
+ if (isPdf) {
+ pageImages = await pdfToImages(fileBuffer);
+ await prisma.processingJob.update({
+ where: { id: jobId },
+ data: { totalPages: pageImages.length },
+ });
+ } else {
+ const processed = await processUploadedImage(fileBuffer);
+ pageImages = [{ buffer: processed, page: 1 }];
+ await prisma.processingJob.update({
+ where: { id: jobId },
+ data: { totalPages: 1 },
+ });
+ }
+
+ const sourceKey = `sources/${jobId}/${fileName}`;
+ await uploadBuffer(sourceKey, fileBuffer, isPdf ? "application/pdf" : "image/jpeg");
+
+ // Pair pages: page 1 = response card (back), page 2 = survey (front), etc.
+ const pairs: { response?: typeof pageImages[0]; survey?: typeof pageImages[0] }[] = [];
+ for (let i = 0; i < pageImages.length; i += 2) {
+ pairs.push({
+ response: pageImages[i],
+ survey: pageImages[i + 1],
+ });
+ }
+
+ // If single image (not PDF), treat as response card side
+ if (!isPdf && pageImages.length === 1) {
+ pairs.length = 0;
+ pairs.push({ response: pageImages[0] });
+ }
+
+ for (const pair of pairs) {
+ const card = await prisma.responseCard.create({
+ data: {
+ sourceFile: sourceKey,
+ ocrStatus: "processing",
+ },
+ });
+ cardIds.push(card.id);
+
+ try {
+ let responseData: Record = {};
+ let surveyData: Record = {};
+ let totalConfidence = 0;
+ let confidenceCount = 0;
+
+ if (pair.response) {
+ const imgKey = `images/${card.id}/response.jpg`;
+ await uploadBuffer(imgKey, pair.response.buffer, "image/jpeg");
+ await prisma.responseCard.update({
+ where: { id: card.id },
+ data: { backImagePath: imgKey },
+ });
+
+ const base64 = await imageToBase64(pair.response.buffer);
+ const result = await ocrImage(base64, "response");
+ responseData = result.data;
+ totalConfidence += result.confidence;
+ confidenceCount++;
+ }
+
+ if (pair.survey) {
+ const imgKey = `images/${card.id}/survey.jpg`;
+ await uploadBuffer(imgKey, pair.survey.buffer, "image/jpeg");
+ await prisma.responseCard.update({
+ where: { id: card.id },
+ data: { frontImagePath: imgKey },
+ });
+
+ const base64 = await imageToBase64(pair.survey.buffer);
+ const result = await ocrImage(base64, "survey");
+ surveyData = result.data;
+ totalConfidence += result.confidence;
+ confidenceCount++;
+ }
+
+ const avgConfidence = confidenceCount > 0 ? totalConfidence / confidenceCount : 0;
+
+ await prisma.responseCard.update({
+ where: { id: card.id },
+ data: {
+ name: asString(responseData.name),
+ gender: asString(responseData.gender),
+ dateOfBirth: asString(responseData.dateOfBirth),
+ maritalStatus: asString(responseData.maritalStatus),
+ maritalStatusOther: asString(responseData.maritalStatusOther),
+ visitType: asString(responseData.visitType),
+ cellPhone: asString(responseData.cellPhone),
+ homePhone: asString(responseData.homePhone),
+ email: asString(responseData.email),
+ address: asString(responseData.address),
+ aptNumber: asString(responseData.aptNumber),
+ city: asString(responseData.city),
+ state: asString(responseData.state),
+ zip: asString(responseData.zip),
+ prayerRequests: asString(responseData.prayerRequests),
+ prayerForTeam: asBool(responseData.prayerForTeam),
+ prayerConfidential: asBool(responseData.prayerConfidential),
+ messageTopics: surveyData.messageTopics ?? [],
+ messageTopicsOther: asString(surveyData.messageTopicsOther),
+ nextStep: surveyData.nextStep ?? [],
+ attendanceDuration: asString(surveyData.attendanceDuration),
+ campusPreference: surveyData.campusPreference ?? [],
+ campusPreferenceOther: asString(surveyData.campusPreferenceOther),
+ howHeard: surveyData.howHeard ?? [],
+ howHeardOther: asString(surveyData.howHeardOther),
+ serviceAttended: asString(surveyData.serviceAttended),
+ ocrStatus: "complete",
+ ocrConfidence: Math.round(avgConfidence),
+ rawOcrResponse: JSON.parse(JSON.stringify({ response: responseData, survey: surveyData })),
+ },
+ });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Unknown OCR error";
+ await prisma.responseCard.update({
+ where: { id: card.id },
+ data: { ocrStatus: "error", ocrError: message },
+ });
+ }
+
+ await prisma.processingJob.update({
+ where: { id: jobId },
+ data: { processed: { increment: 1 } },
+ });
+ }
+
+ await prisma.processingJob.update({
+ where: { id: jobId },
+ data: { status: "complete", cardIds },
+ });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Unknown error";
+ await prisma.processingJob.update({
+ where: { id: jobId },
+ data: { status: "error", error: message },
+ });
+ }
+
+ return cardIds;
+}
+
+function asString(v: unknown): string | null {
+ if (v === null || v === undefined) return null;
+ return String(v);
+}
+
+function asBool(v: unknown): boolean {
+ return v === true;
+}
diff --git a/src/lib/ollama.ts b/src/lib/ollama.ts
new file mode 100644
index 0000000..45ae6b6
--- /dev/null
+++ b/src/lib/ollama.ts
@@ -0,0 +1,118 @@
+import { prisma } from "./db";
+
+async function getSettings() {
+ let settings = await prisma.appSettings.findUnique({ where: { id: "singleton" } });
+ if (!settings) {
+ settings = await prisma.appSettings.create({
+ data: { id: "singleton" },
+ });
+ }
+ return settings;
+}
+
+function getOllamaUrl() {
+ return process.env.OLLAMA_BASE_URL || "http://192.168.68.108:11434";
+}
+
+async function getModel() {
+ const settings = await getSettings();
+ return process.env.OLLAMA_MODEL || settings.model;
+}
+
+const RESPONSE_CARD_PROMPT = `You are analyzing a scanned church response card. This is the PERSONAL INFORMATION side.
+
+Extract ALL of the following fields from the image. For checkboxes, determine if they are checked or unchecked.
+For handwritten text, read it as accurately as possible.
+
+Return ONLY valid JSON with this exact structure (no markdown, no code fences):
+{
+ "name": "string or null",
+ "gender": "Male" or "Female" or null,
+ "dateOfBirth": "string as written or null",
+ "maritalStatus": "Married" or "Single" or "Other" or null,
+ "maritalStatusOther": "string if Other is checked, else null",
+ "visitType": "First/Second Time Guest" or "Update My Information" or null,
+ "cellPhone": "string or null",
+ "homePhone": "string or null",
+ "email": "string or null",
+ "address": "string or null",
+ "aptNumber": "string or null",
+ "city": "string or null",
+ "state": "string or null",
+ "zip": "string or null",
+ "prayerRequests": "string or null",
+ "prayerForTeam": true/false,
+ "prayerConfidential": true/false,
+ "confidence": 0-100
+}`;
+
+const SURVEY_PROMPT = `You are analyzing a scanned church Easter survey form. This is the SURVEY side.
+
+Extract ALL of the following fields. For checkboxes, determine if they are checked (filled/marked) or unchecked (empty).
+
+Return ONLY valid JSON with this exact structure (no markdown, no code fences):
+{
+ "messageTopics": ["array of checked topics from: Stress, Marriage, Revival, Addiction, Parenting, Miracles, Forgiveness, Finances, My Identity, Conflict Resolution, The Holy Spirit, Understanding The Bible, Spiritual Warfare, Sharing My Faith, Anxiety, Heaven, Spiritual Gifts"],
+ "messageTopicsOther": "string if Other is filled in, else null",
+ "nextStep": ["array of checked items from: Baptism, Next Steps"],
+ "attendanceDuration": "Less than 6 months" or "6 Months - 1 Year" or "1-3 Years" or "4-6 Years" or "7+ Years" or null,
+ "campusPreference": ["array of checked locations from: Beulah, Pace/Milton, Gulf Breeze, Warrington"],
+ "campusPreferenceOther": "string if Other is filled in, else null",
+ "howHeard": ["array of checked items from: This is my church home, Regular Attender, Drove by, Social Media, Google, Personal Invite"],
+ "howHeardOther": "string if Other is filled in, else null",
+ "serviceAttended": "A" or "B" or "C" or "D" or null,
+ "confidence": 0-100
+}`;
+
+export interface OcrResult {
+ data: Record;
+ confidence: number;
+ raw: string;
+ side: "response" | "survey";
+}
+
+export async function ocrImage(
+ imageBase64: string,
+ side: "response" | "survey"
+): Promise {
+ const ollamaUrl = getOllamaUrl();
+ const model = await getModel();
+ const prompt = side === "response" ? RESPONSE_CARD_PROMPT : SURVEY_PROMPT;
+
+ const response = await fetch(`${ollamaUrl}/api/generate`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ model,
+ prompt,
+ images: [imageBase64],
+ stream: false,
+ options: {
+ temperature: 0.1,
+ num_predict: 2048,
+ },
+ }),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`Ollama API error (${response.status}): ${errorText}`);
+ }
+
+ const result = await response.json();
+ const rawText = result.response || "";
+
+ let parsed: Record;
+ try {
+ const jsonMatch = rawText.match(/\{[\s\S]*\}/);
+ if (!jsonMatch) throw new Error("No JSON found in response");
+ parsed = JSON.parse(jsonMatch[0]);
+ } catch {
+ throw new Error(`Failed to parse OCR response: ${rawText.slice(0, 500)}`);
+ }
+
+ const confidence = typeof parsed.confidence === "number" ? parsed.confidence : 50;
+ delete parsed.confidence;
+
+ return { data: parsed, confidence, raw: rawText, side };
+}
diff --git a/src/lib/pdf.ts b/src/lib/pdf.ts
new file mode 100644
index 0000000..3bd5bb8
--- /dev/null
+++ b/src/lib/pdf.ts
@@ -0,0 +1,60 @@
+import { fromBuffer } from "pdf2pic";
+import sharp from "sharp";
+
+export interface PageImage {
+ page: number;
+ buffer: Buffer;
+ width: number;
+ height: number;
+}
+
+export async function pdfToImages(pdfBuffer: Buffer): Promise {
+ const converter = fromBuffer(pdfBuffer, {
+ density: 200,
+ format: "jpeg",
+ width: 1600,
+ height: 2200,
+ quality: 90,
+ });
+
+ const pageCount = await getPdfPageCount(pdfBuffer);
+ const images: PageImage[] = [];
+
+ for (let i = 1; i <= pageCount; i++) {
+ const result = await converter(i, { responseType: "buffer" });
+ if (result.buffer) {
+ const metadata = await sharp(result.buffer).metadata();
+ images.push({
+ page: i,
+ buffer: result.buffer as Buffer,
+ width: metadata.width || 1600,
+ height: metadata.height || 2200,
+ });
+ }
+ }
+
+ return images;
+}
+
+async function getPdfPageCount(pdfBuffer: Buffer): Promise {
+ const text = pdfBuffer.toString("latin1");
+ const matches = text.match(/\/Type\s*\/Page(?!s)/g);
+ return matches ? matches.length : 2;
+}
+
+export async function imageToBase64(buffer: Buffer): Promise {
+ const processed = await sharp(buffer)
+ .resize(1200, undefined, { withoutEnlargement: true })
+ .jpeg({ quality: 85 })
+ .toBuffer();
+ return processed.toString("base64");
+}
+
+export async function processUploadedImage(buffer: Buffer): Promise {
+ return sharp(buffer)
+ .resize(1600, undefined, { withoutEnlargement: true })
+ .normalize()
+ .sharpen()
+ .jpeg({ quality: 90 })
+ .toBuffer();
+}
diff --git a/src/lib/watcher.ts b/src/lib/watcher.ts
new file mode 100644
index 0000000..46e9516
--- /dev/null
+++ b/src/lib/watcher.ts
@@ -0,0 +1,92 @@
+import chokidar, { type FSWatcher } from "chokidar";
+import fs from "fs/promises";
+import path from "path";
+import { prisma } from "./db";
+import { processFile } from "./ocr";
+
+let watcher: FSWatcher | null = null;
+
+const processedFiles = new Set();
+
+export async function startWatching(watchDir: string): Promise {
+ if (watcher) {
+ await stopWatching();
+ }
+
+ try {
+ await fs.access(watchDir);
+ } catch {
+ throw new Error(`Watch directory does not exist: ${watchDir}`);
+ }
+
+ watcher = chokidar.watch(watchDir, {
+ ignored: /(^|[/\\])\../,
+ persistent: true,
+ ignoreInitial: false,
+ awaitWriteFinish: {
+ stabilityThreshold: 2000,
+ pollInterval: 500,
+ },
+ });
+
+ watcher.on("add", async (filePath: string) => {
+ const ext = path.extname(filePath).toLowerCase();
+ const allowed = [".pdf", ".jpg", ".jpeg", ".png", ".webp"];
+ if (!allowed.includes(ext)) return;
+ if (processedFiles.has(filePath)) return;
+ processedFiles.add(filePath);
+
+ const fileName = path.basename(filePath);
+ const isPdf = ext === ".pdf";
+
+ try {
+ const buffer = await fs.readFile(filePath);
+ const job = await prisma.processingJob.create({
+ data: {
+ fileName,
+ filePath,
+ status: "queued",
+ },
+ });
+
+ processFile(job.id, fileName, buffer, isPdf).catch((err) => {
+ console.error(`[watcher] Processing failed for ${fileName}:`, err);
+ });
+
+ console.log(`[watcher] Queued: ${fileName}`);
+ } catch (err) {
+ console.error(`[watcher] Failed to read file ${filePath}:`, err);
+ }
+ });
+
+ watcher.on("error", (error: unknown) => {
+ console.error("[watcher] Error:", error);
+ });
+
+ await prisma.appSettings.upsert({
+ where: { id: "singleton" },
+ update: { watching: true, watchDir },
+ create: { id: "singleton", watching: true, watchDir },
+ });
+
+ console.log(`[watcher] Started watching: ${watchDir}`);
+}
+
+export async function stopWatching(): Promise {
+ if (watcher) {
+ await watcher.close();
+ watcher = null;
+ }
+
+ await prisma.appSettings.upsert({
+ where: { id: "singleton" },
+ update: { watching: false },
+ create: { id: "singleton", watching: false },
+ });
+
+ console.log("[watcher] Stopped");
+}
+
+export function isWatching(): boolean {
+ return watcher !== null;
+}