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,
+}
\ No newline at end of file
diff --git a/src/components/ui/combobox.tsx b/src/components/ui/combobox.tsx
new file mode 100644
index 0000000..eb85502
--- /dev/null
+++ b/src/components/ui/combobox.tsx
@@ -0,0 +1,295 @@
+import * as React from "react"
+import { Combobox as ComboboxPrimitive } from "@base-ui/react"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupInput,
+} from "@/components/ui/input-group"
+import { IconChevronDown, IconX, IconCheck } from "@tabler/icons-react"
+
+const Combobox = ComboboxPrimitive.Root
+
+function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
+ return
+}
+
+function ComboboxTrigger({
+ className,
+ children,
+ ...props
+}: ComboboxPrimitive.Trigger.Props) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
+ return (
+
}
+ className={cn(className)}
+ {...props}
+ >
+
+
+ )
+}
+
+function ComboboxInput({
+ className,
+ children,
+ disabled = false,
+ showTrigger = true,
+ showClear = false,
+ ...props
+}: ComboboxPrimitive.Input.Props & {
+ showTrigger?: boolean
+ showClear?: boolean
+}) {
+ return (
+
+ }
+ {...props}
+ />
+
+ {showTrigger && (
+ }
+ data-slot="input-group-button"
+ className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
+ disabled={disabled}
+ />
+ )}
+ {showClear && }
+
+ {children}
+
+ )
+}
+
+function ComboboxContent({
+ className,
+ side = "bottom",
+ sideOffset = 6,
+ align = "start",
+ alignOffset = 0,
+ anchor,
+ ...props
+}: ComboboxPrimitive.Popup.Props &
+ Pick<
+ ComboboxPrimitive.Positioner.Props,
+ "side" | "align" | "sideOffset" | "alignOffset" | "anchor"
+ >) {
+ return (
+
+
+
+
+
+ )
+}
+
+function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
+ return (
+
+ )
+}
+
+function ComboboxItem({
+ className,
+ children,
+ ...props
+}: ComboboxPrimitive.Item.Props) {
+ return (
+
+ {children}
+
+ }
+ >
+
+
+
+ )
+}
+
+function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
+ return (
+
+ )
+}
+
+function ComboboxLabel({
+ className,
+ ...props
+}: ComboboxPrimitive.GroupLabel.Props) {
+ return (
+
+ )
+}
+
+function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
+ return (
+
+ )
+}
+
+function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
+ return (
+
+ )
+}
+
+function ComboboxSeparator({
+ className,
+ ...props
+}: ComboboxPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function ComboboxChips({
+ className,
+ ...props
+}: React.ComponentPropsWithRef
&
+ ComboboxPrimitive.Chips.Props) {
+ return (
+
+ )
+}
+
+function ComboboxChip({
+ className,
+ children,
+ showRemove = true,
+ ...props
+}: ComboboxPrimitive.Chip.Props & {
+ showRemove?: boolean
+}) {
+ return (
+
+ {children}
+ {showRemove && (
+ }
+ className="-ml-1 opacity-50 hover:opacity-100"
+ data-slot="combobox-chip-remove"
+ >
+
+
+ )}
+
+ )
+}
+
+function ComboboxChipsInput({
+ className,
+ ...props
+}: ComboboxPrimitive.Input.Props) {
+ return (
+
+ )
+}
+
+function useComboboxAnchor() {
+ return React.useRef(null)
+}
+
+export {
+ Combobox,
+ ComboboxInput,
+ ComboboxContent,
+ ComboboxList,
+ ComboboxItem,
+ ComboboxGroup,
+ ComboboxLabel,
+ ComboboxCollection,
+ ComboboxEmpty,
+ ComboboxSeparator,
+ ComboboxChips,
+ ComboboxChip,
+ ComboboxChipsInput,
+ ComboboxTrigger,
+ ComboboxValue,
+ useComboboxAnchor,
+}
diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx
new file mode 100644
index 0000000..59955de
--- /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 { IconSearch, IconCheck } from "@tabler/icons-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/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..cd98ec5
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -0,0 +1,158 @@
+"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 { IconX } from "@tabler/icons-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/drawer.tsx b/src/components/ui/drawer.tsx
new file mode 100644
index 0000000..db7e63a
--- /dev/null
+++ b/src/components/ui/drawer.tsx
@@ -0,0 +1,155 @@
+import * as React from "react"
+import { Drawer as DrawerPrimitive } from "@base-ui/react/drawer"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { IconX } from "@tabler/icons-react"
+
+type DrawerSwipeDirection = "up" | "down" | "left" | "right"
+
+function Drawer({ ...props }: DrawerPrimitive.Root.Props) {
+ return
+}
+
+function DrawerTrigger({ ...props }: DrawerPrimitive.Trigger.Props) {
+ return
+}
+
+function DrawerClose({ ...props }: DrawerPrimitive.Close.Props) {
+ return
+}
+
+function DrawerPortal({ ...props }: DrawerPrimitive.Portal.Props) {
+ return
+}
+
+function DrawerBackdrop({
+ className,
+ ...props
+}: DrawerPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function DrawerContent({
+ className,
+ children,
+ swipeDirection = "right",
+ showCloseButton = true,
+ ...props
+}: DrawerPrimitive.Popup.Props & {
+ swipeDirection?: DrawerSwipeDirection
+ showCloseButton?: boolean
+}) {
+ const swipeClasses: Record = {
+ right:
+ "inset-y-0 right-0 h-full w-full sm:w-3/4 sm:max-w-3xl border-l translate-x-[var(--drawer-swipe-movement-x)] data-starting-style:translate-x-full data-ending-style:translate-x-full",
+ left:
+ "inset-y-0 left-0 h-full w-full sm:w-3/4 sm:max-w-md border-r translate-x-[var(--drawer-swipe-movement-x)] data-starting-style:-translate-x-full data-ending-style:-translate-x-full",
+ down:
+ "inset-x-0 bottom-0 w-full h-full sm:h-auto sm:max-h-[85vh] border-t translate-y-[calc(var(--drawer-snap-point-offset)+var(--drawer-swipe-movement-y))] data-starting-style:translate-y-full data-ending-style:translate-y-full",
+ up:
+ "inset-x-0 top-0 w-full h-full sm:h-auto sm:max-h-[85vh] border-b translate-y-[var(--drawer-swipe-movement-y)] data-starting-style:-translate-y-full data-ending-style:-translate-y-full",
+ }
+
+ return (
+
+
+
+
+
+ {children}
+
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+
+ )
+}
+
+function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DrawerTitle({ className, ...props }: DrawerPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function DrawerDescription({
+ className,
+ ...props
+}: DrawerPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Drawer,
+ DrawerTrigger,
+ DrawerClose,
+ DrawerPortal,
+ DrawerBackdrop,
+ DrawerContent,
+ DrawerHeader,
+ DrawerFooter,
+ DrawerTitle,
+ DrawerDescription,
+}
diff --git a/src/components/ui/input-group.tsx b/src/components/ui/input-group.tsx
new file mode 100644
index 0000000..bb9245a
--- /dev/null
+++ b/src/components/ui/input-group.tsx
@@ -0,0 +1,156 @@
+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-[[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-1 has-[>kbd]:ml-[-0.15rem]",
+ "inline-end":
+ "order-last pr-2 has-[>button]:-mr-1 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)-5px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
+ sm: "",
+ "icon-xs":
+ "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
+ "icon-sm": "size-8 p-0 has-[>svg]:p-0",
+ },
+ },
+ defaultVariants: {
+ size: "xs",
+ },
+ }
+)
+
+function InputGroupButton({
+ className,
+ type = "button",
+ variant = "ghost",
+ size = "xs",
+ ...props
+}: Omit
, "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..82ccce4
--- /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..f162996
--- /dev/null
+++ b/src/components/ui/label.tsx
@@ -0,0 +1,18 @@
+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..85a2862
--- /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/select.tsx b/src/components/ui/select.tsx
new file mode 100644
index 0000000..1607593
--- /dev/null
+++ b/src/components/ui/select.tsx
@@ -0,0 +1,199 @@
+import * as React from "react"
+import { Select as SelectPrimitive } from "@base-ui/react/select"
+
+import { cn } from "@/lib/utils"
+import { IconSelector, IconCheck, IconChevronUp, IconChevronDown } from "@tabler/icons-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..452a855
--- /dev/null
+++ b/src/components/ui/sheet.tsx
@@ -0,0 +1,133 @@
+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 { IconX } from "@tabler/icons-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..48a020d
--- /dev/null
+++ b/src/components/ui/switch.tsx
@@ -0,0 +1,30 @@
+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/tabs.tsx b/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..61d8caf
--- /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-9 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..95ed1c4
--- /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/toggle-group.tsx b/src/components/ui/toggle-group.tsx
new file mode 100644
index 0000000..06d236a
--- /dev/null
+++ b/src/components/ui/toggle-group.tsx
@@ -0,0 +1,89 @@
+"use client"
+
+import * as React from "react"
+import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
+import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
+import { type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+import { toggleVariants } from "@/components/ui/toggle"
+
+const ToggleGroupContext = React.createContext<
+ VariantProps & {
+ spacing?: number
+ orientation?: "horizontal" | "vertical"
+ }
+>({
+ size: "default",
+ variant: "default",
+ spacing: 2,
+ orientation: "horizontal",
+})
+
+function ToggleGroup({
+ className,
+ variant,
+ size,
+ spacing = 2,
+ orientation = "horizontal",
+ children,
+ ...props
+}: ToggleGroupPrimitive.Props &
+ VariantProps & {
+ spacing?: number
+ orientation?: "horizontal" | "vertical"
+ }) {
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function ToggleGroupItem({
+ className,
+ children,
+ variant = "default",
+ size = "default",
+ ...props
+}: TogglePrimitive.Props & VariantProps) {
+ const context = React.useContext(ToggleGroupContext)
+
+ return (
+
+ {children}
+
+ )
+}
+
+export { ToggleGroup, ToggleGroupItem }
diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx
new file mode 100644
index 0000000..3f90429
--- /dev/null
+++ b/src/components/ui/toggle.tsx
@@ -0,0 +1,43 @@
+import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const toggleVariants = cva(
+ "group/toggle inline-flex items-center justify-center gap-1 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ outline: "border border-input bg-transparent shadow-xs hover:bg-muted",
+ },
+ size: {
+ default:
+ "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ sm: "h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
+ lg: "h-10 min-w-10 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Toggle({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: TogglePrimitive.Props & VariantProps) {
+ return (
+
+ )
+}
+
+export { Toggle, toggleVariants }
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..96a9ec2
--- /dev/null
+++ b/src/components/ui/tooltip.tsx
@@ -0,0 +1,64 @@
+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/composable/useHints.ts b/src/composable/useHints.ts
deleted file mode 100644
index d49869b..0000000
--- a/src/composable/useHints.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { type MaybeRefOrGetter, ref, toValue, watchEffect } from "vue";
-import type { UseSearchResult } from "./useSearch";
-
-export const getHints = (result: UseSearchResult) => {
- if (!("terms" in result)) {
- return result;
- }
- const highlighted = { ...result };
- for (const term of result.terms) {
- for (const field of result.match[term]) {
- if (!["title", "questionRaw", "answerRaw"].includes(field)) {
- continue;
- }
- if (field !== "title") {
- highlighted[field] = highlighted[field].replace(
- new RegExp(`(${term})`, "gi"),
- "$1 ",
- );
- }
- }
- }
- return highlighted;
-};
-
-export const useHints = (results: MaybeRefOrGetter) => {
- const highlightedQuestions = ref([]);
- const highlight = () => {
- const resultsValue = toValue(results);
- highlightedQuestions.value = resultsValue.map(getHints);
- };
- watchEffect(() => highlight());
-
- return { highlightedQuestions };
-};
diff --git a/src/composable/useSearch.ts b/src/composable/useSearch.ts
deleted file mode 100644
index cd6aa70..0000000
--- a/src/composable/useSearch.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-import type { Question } from "@qnaplus/scraper";
-import MiniSearch, { type SearchResult } from "minisearch";
-import { stemmer } from "stemmer";
-import {
- type MaybeRefOrGetter,
- type Ref,
- ref,
- toValue,
- watchEffect,
-} from "vue";
-import { isEmpty } from "../util/strings";
-
-export type QuestionSearchResult = Question & SearchResult;
-
-export type UseSearchResult = Question | QuestionSearchResult;
-
-const HTML_TOKENIZER_REGEX = /(?:[\n\r\p{Z}\p{P}]|(?:<\/?\w+>))+/gu;
-
-const minisearch = new MiniSearch({
- fields: ["title", "author", "questionRaw", "answerRaw", "tags"],
- storeFields: [
- "id",
- "url",
- "author",
- "program",
- "title",
- "question",
- "questionRaw",
- "answer",
- "answerRaw",
- "season",
- "askedTimestamp",
- "askedTimestampMs",
- "answeredTimestamp",
- "answeredTimestampMs",
- "answered",
- "tags",
- ],
- tokenize: (text) =>
- text.split(HTML_TOKENIZER_REGEX).filter((t) => !isEmpty(t)),
-});
-
-let loaded = false;
-
-export const loadMinisearch = async (questions: Question[]) => {
- if (loaded) {
- return;
- }
- try {
- await minisearch.addAllAsync(questions, {
- chunkSize: 50,
- });
- loaded = true;
- } catch (e) {
- console.error(e);
- }
-};
-
-export const useKeywordSearch = (
- query: MaybeRefOrGetter,
- dbQuestions: Readonly[>,
-) => {
- const questions = ref]([]);
-
- const search = () => {
- const value = toValue(query);
- if (dbQuestions.value === undefined) {
- questions.value = [];
- } else if (isEmpty(value)) {
- questions.value = dbQuestions.value;
- } else {
- const results = minisearch.search(value, {
- fields: ["title", "questionRaw", "answerRaw"],
- processTerm: (term) => stemmer(term).toLowerCase(),
- prefix: true,
- }) as QuestionSearchResult[];
- questions.value = results;
- }
- };
-
- watchEffect(() => search());
-
- return { questions };
-};
-
-export const getAuthorSuggestions = (
- author: MaybeRefOrGetter,
-) => {
- const value = toValue(author);
- if (value === null) {
- return [];
- }
- const results = minisearch.search(value, {
- fields: ["author"],
- prefix: true,
- }) as QuestionSearchResult[];
- return [...new Set(results.map((r) => r.author))];
-};
-
-export const getTagSuggestions = (
- tag: MaybeRefOrGetter,
-) => {
- const value = toValue(tag);
- if (value === undefined) {
- return [];
- }
- const results = minisearch.search(value, {
- fields: ["tags"],
- prefix: true,
- }) as QuestionSearchResult[];
-
- const isMatchingTag = (result: QuestionSearchResult) =>
- result.tags.filter((t) => result.terms.includes(t.toLowerCase()));
-
- return [...new Set(results.flatMap(isMatchingTag).sort())];
-};
diff --git a/src/composable/useSearchFilter.ts b/src/composable/useSearchFilter.ts
deleted file mode 100644
index 490df7f..0000000
--- a/src/composable/useSearchFilter.ts
+++ /dev/null
@@ -1,184 +0,0 @@
-import type { Question } from "@qnaplus/scraper";
-import {
- type MaybeRefOrGetter,
- type Ref,
- reactive,
- ref,
- toValue,
- watchEffect,
-} from "vue";
-import type { Option } from "./types";
-import type { UseSearchResult } from "./useSearch";
-
-export type SearchFilters = {
- season: Option[];
- program: Option[];
- author: Question["author"] | null;
- state: Option;
- askedBefore: Date | null;
- askedAfter: Date | null;
- answeredBefore: Date | null;
- answeredAfter: Date | null;
- tags: string[];
-};
-
-export enum QuestionStateValue {
- All = 0,
- Answered = 1,
- Unanswered = 2,
-}
-
-export const questionStateOptions: Option[] = [
- { name: "All", value: QuestionStateValue.All },
- { name: "Answered", value: QuestionStateValue.Answered },
- { name: "Unanswered", value: QuestionStateValue.Unanswered },
-];
-
-type FilterMap = {
- [K in keyof SearchFilters]: (
- question: Question,
- filters: SearchFilters,
- ) => boolean;
-};
-
-const FILTER_MAP: FilterMap = {
- season(q, f) {
- return f.season.find((s) => s.value === q.season) !== undefined;
- },
- program(q, f) {
- return f.program.find((p) => p.value === q.program) !== undefined;
- },
- author(q, f) {
- return q.author.includes(f.author ?? "");
- },
- state(q, f) {
- if (f.state.value === QuestionStateValue.All) {
- return true;
- }
- return f.state.value === QuestionStateValue.Answered
- ? q.answered
- : !q.answered;
- },
- askedBefore(q, f) {
- if (q.askedTimestampMs === null || f.askedBefore === null) {
- return false;
- }
- return new Date(q.askedTimestampMs) < f.askedBefore;
- },
- askedAfter(q, f) {
- if (q.askedTimestampMs === null || f.askedAfter === null) {
- return false;
- }
- return new Date(q.askedTimestampMs) > f.askedAfter;
- },
- answeredBefore(q, f) {
- if (q.askedTimestampMs === null || f.answeredBefore === null) {
- return false;
- }
- return new Date(q.askedTimestampMs) < f.answeredBefore;
- },
- answeredAfter(q, f) {
- if (q.askedTimestampMs === null || f.answeredAfter === null) {
- return false;
- }
- return new Date(q.askedTimestampMs) > f.answeredAfter;
- },
- tags(q, f) {
- return f.tags.every((t) => q.tags.includes(t));
- },
-};
-
-const isEmptyFilterValue = (
- filterValue: SearchFilters[keyof SearchFilters],
-): boolean => {
- if (typeof filterValue === "string") {
- return filterValue.trim() === "";
- }
- if (Array.isArray(filterValue)) {
- return filterValue.length === 0;
- }
- return filterValue === null;
-};
-
-export type SearchFilterComposable = {
- filters: SearchFilters;
- filteredQuestions: Ref;
- appliedFilterCount: Ref;
- clearFilters(): void;
- seasons: Option[];
- programs: Option[];
-};
-
-export type SearchFilterOptions = Omit<
- SearchFilterComposable,
- "filteredQuestions"
->;
-
-export type FilterData = {
- programs: string[];
- seasons: string[];
-};
-
-export const useSearchFilter = (
- questions: MaybeRefOrGetter,
- filterData: FilterData,
-): SearchFilterComposable => {
- const seasons = filterData.seasons.map((season) => ({
- name: season,
- value: season,
- }));
- const programs = filterData.programs.map((program) => ({
- name: program,
- value: program,
- }));
-
- const getInitialFilterState = () => {
- return {
- season: [seasons[0]],
- program: [],
- author: null,
- state: {
- name: "All",
- value: QuestionStateValue.All,
- },
- askedBefore: null,
- askedAfter: null,
- answeredBefore: null,
- answeredAfter: null,
- tags: [],
- };
- };
-
- const clearFilters = () => {
- Object.assign(filters, getInitialFilterState());
- };
-
- const filters = reactive(getInitialFilterState());
- const filteredQuestions = ref([]);
- const appliedFilterCount = ref(0);
-
- const applyFilters = () => {
- const value = toValue(questions);
- const keys = Object.keys(filters) as Array;
- const applicableFilters = keys
- .filter((k) => !isEmptyFilterValue(filters[k]))
- .map((k) => FILTER_MAP[k]);
- appliedFilterCount.value =
- applicableFilters.length -
- +(filters.state.value === QuestionStateValue.All);
- filteredQuestions.value = value.filter((q) =>
- applicableFilters.every((f) => f(q, filters)),
- );
- };
-
- watchEffect(() => applyFilters());
-
- return {
- filters,
- filteredQuestions,
- clearFilters,
- seasons,
- programs,
- appliedFilterCount,
- };
-};
diff --git a/src/composable/useSort.ts b/src/composable/useSort.ts
deleted file mode 100644
index 69a7b53..0000000
--- a/src/composable/useSort.ts
+++ /dev/null
@@ -1,148 +0,0 @@
-import type { Question } from "@qnaplus/scraper";
-import {
- type MaybeRefOrGetter,
- reactive,
- ref,
- toValue,
- watchEffect,
-} from "vue";
-import { type SortFunction, multisortrules } from "../util/sorting";
-import type { Option } from "./types";
-import type { UseSearchResult } from "./useSearch";
-
-export enum SortOptions {
- Season = 0,
- Author = 1,
- QuestionState = 2,
- AskDate = 3,
- AnswerDate = 4,
-}
-
-export enum SortOrder {
- Ascending = 0,
- Descending = 1,
-}
-
-export type BasicSortOption = {
- sort: Option;
- asc: Option;
-};
-
-export type AdvancedSortOption = Option & {
- asc: Option;
-};
-
-export interface SearchSortOptions {
- basic: {
- sort: Option;
- asc: Option;
- };
- advanced: AdvancedSortOption[];
- advancedEnabled: boolean;
-}
-
-type SortMap = {
- [K in SortOptions]: SortFunction;
-};
-
-const SORT_MAP: SortMap = {
- [SortOptions.Season]: (a, b) =>
- Number.parseInt(b.season.split("-")[1]) -
- Number.parseInt(a.season.split("-")[1]),
- [SortOptions.Author]: (a, b) => b.author.localeCompare(a.author),
- [SortOptions.QuestionState]: (a, b) => +b.answered - +a.answered,
- [SortOptions.AskDate]: (a, b) =>
- Number.parseInt(b.id) - Number.parseInt(a.id) + (b.answeredTimestampMs ?? 0) - (a.answeredTimestampMs ?? 0),
- [SortOptions.AnswerDate]: (a, b) =>
- (Number.parseInt(b.id) - Number.parseInt(a.id)) + (b.answeredTimestampMs ?? 0) - (a.answeredTimestampMs ?? 0),
-};
-
-export const sortOrderList: Option[] = [
- {
- name: "Ascending",
- value: SortOrder.Ascending,
- },
- {
- name: "Descending",
- value: SortOrder.Descending,
- },
-];
-
-export const sortOptionsList = [
- {
- name: "Ask Date",
- value: SortOptions.AskDate,
- },
- {
- name: "Answer Date",
- value: SortOptions.AnswerDate,
- },
- {
- name: "Season",
- value: SortOptions.Season,
- },
- {
- name: "Author",
- value: SortOptions.Author,
- },
- {
- name: "Question State",
- value: SortOptions.QuestionState,
- },
-];
-
-export const useSort = (questions: MaybeRefOrGetter) => {
- const getDefaultSearchSortOptions = (): SearchSortOptions => {
- return {
- basic: {
- sort: {
- name: "Ask Date",
- value: SortOptions.AskDate,
- },
- asc: {
- name: "Descending",
- value: SortOrder.Descending,
- },
- },
- advanced: [],
- advancedEnabled: false,
- };
- };
-
- const sortOptions = reactive(getDefaultSearchSortOptions());
- const sortedQuestions = ref([]);
-
- const doBasicSort = (questions: Question[]) => {
- const questionsCopy = [...questions];
- const sortFn = SORT_MAP[sortOptions.basic.sort.value];
- questionsCopy.sort((a, b) => {
- if (sortOptions.basic.asc.value === SortOrder.Ascending) {
- return sortFn(b, a);
- }
- return sortFn(a, b);
- });
- return questionsCopy;
- };
-
- const doAdvancedSort = (questions: Question[]): Question[] => {
- const questionsCopy = [...questions];
- const sorts = sortOptions.advanced.map((s) => ({
- sort: SORT_MAP[s.value],
- asc: s.asc.value === SortOrder.Ascending,
- }));
- return multisortrules(questionsCopy, sorts);
- };
-
- const applySorts = () => {
- const questionsValue = toValue(questions);
- if (sortOptions.advancedEnabled) {
- sortedQuestions.value = doAdvancedSort(questionsValue);
- return;
- }
- sortedQuestions.value = doBasicSort(questionsValue);
- };
-
- watchEffect(() => applySorts());
-
- return { sortedQuestions, sortOptions };
-};
diff --git a/src/database.ts b/src/database.ts
index 31a0606..2669572 100644
--- a/src/database.ts
+++ b/src/database.ts
@@ -1,130 +1,136 @@
import type { Question } from "@qnaplus/scraper";
import Dexie, { type EntityTable } from "dexie";
+import { trycatch } from "./util/try";
const DATA_PRIMARY_KEY = "0";
const DEFAULT_VERSION = "_";
export interface QnaplusAppData {
- id: string;
- seasons: string[];
- programs: string[];
+ id: string;
+ seasons: string[];
+ programs: string[];
}
export interface QnaplusMetadata {
- id: string;
- version: string;
+ id: string;
+ version: string;
}
interface QnaplusDatabase extends Dexie {
- questions: EntityTable;
- metadata: EntityTable;
- appdata: EntityTable;
+ questions: EntityTable;
+ metadata: EntityTable;
+ appdata: EntityTable;
}
export const database = new Dexie("qnaplus", {
- autoOpen: true,
+ autoOpen: true,
}) as QnaplusDatabase;
database.version(1).stores({
- questions: "id",
- metadata: "id",
- appdata: "id",
+ questions: "id",
+ metadata: "id",
+ appdata: "id",
});
database.version(2).upgrade((tx) => {
- console.info("Upgrading database to version 2");
- tx.table("metadata")
- .toCollection()
- .modify((metadata) => {
- metadata.version = DEFAULT_VERSION;
- // biome-ignore lint/performance/noDelete: it's literally just one row lol
- delete metadata.lastUpdated;
- });
- tx.table("questions").clear();
+ console.info("Upgrading database to version 2");
+ tx.table("metadata")
+ .toCollection()
+ .modify((metadata) => {
+ metadata.version = DEFAULT_VERSION;
+ delete metadata.lastUpdated;
+ });
+ tx.table("questions").clear();
});
const updateAppData = async (db: QnaplusDatabase) => {
- const questions = await db.questions.toArray();
- const seasons = questions
- .map((q) => q.season)
- .sort(
- (a, b) =>
- Number.parseInt(b.split("-")[1]) - Number.parseInt(a.split("-")[1]),
- )
- .filter((season, index, array) => array.indexOf(season) === index);
- const programs = questions
- .map((q) => q.program)
- .filter((program, index, array) => array.indexOf(program) === index);
- await db.appdata.put({ id: DATA_PRIMARY_KEY, seasons, programs });
+ const questions = await db.questions.toArray();
+ const seasons = questions
+ .map((q) => q.season)
+ .sort(
+ (a, b) =>
+ Number.parseInt(b.split("-")[1]) - Number.parseInt(a.split("-")[1]),
+ )
+ .filter((season, index, array) => array.indexOf(season) === index);
+ const programs = questions
+ .map((q) => q.program)
+ .filter((program, index, array) => array.indexOf(program) === index);
+ await db.appdata.put({ id: DATA_PRIMARY_KEY, seasons, programs });
};
type UpdateResponseOutdated = {
- outdated: true;
- version: string;
- questions: Question[];
+ outdated: true;
+ version: string;
+ questions: Question[];
};
type UpdateResponseUpToDate = {
- outdated: false;
+ outdated: false;
};
type UpdateResponse = UpdateResponseOutdated | UpdateResponseUpToDate;
const update = async (db: QnaplusDatabase) => {
- const metadata = await getMetadata(db);
- const response = await fetch(
- `${import.meta.env.VITE_QNAPLUS_API}/internal/update?version=${metadata.version}`,
- );
- if (response.status === 500) {
- console.error(response.status, response.statusText);
- return;
- }
- const updateResponse = (await response.json()) as UpdateResponse;
- if (!updateResponse.outdated) {
- return;
- }
- const { questions, version } = updateResponse;
- await db.questions.bulkPut(questions);
- await db.metadata.put({ id: DATA_PRIMARY_KEY, version });
- await updateAppData(db);
+ const metadata = await getMetadata(db);
+ const [responseErr, response] = await trycatch(() =>
+ fetch(
+ `${import.meta.env.VITE_QNAPLUS_API}/internal/update?version=${metadata.version}`,
+ )
+ );
+ if (responseErr) {
+ console.error(responseErr);
+ return;
+ }
+ if (response.status === 500) {
+ console.error(response.status, response.statusText);
+ return;
+ }
+ const updateResponse = (await response.json()) as UpdateResponse;
+ if (!updateResponse.outdated) {
+ return;
+ }
+ const { questions, version } = updateResponse;
+ await db.questions.bulkPut(questions);
+ await db.metadata.put({ id: DATA_PRIMARY_KEY, version });
+ await updateAppData(db);
};
export const setupDatabase = async () => {
- return new Promise((resolve, reject) => {
- database.open().catch((e) => reject(e));
- database.on("ready", async (db) => {
- try {
- await update(db as QnaplusDatabase);
- resolve();
- return;
- } catch (e) {
- reject();
- return;
- }
- });
- });
+ return new Promise((resolve, reject) => {
+ database.open().catch((e) => reject(e));
+ database.on("ready", async (db) => {
+ try {
+ await update(db as QnaplusDatabase);
+ resolve();
+ return;
+ } catch (e) {
+ reject();
+ return;
+ }
+ });
+ });
};
export const getMetadata = async (
- db: QnaplusDatabase,
+ db: QnaplusDatabase,
): Promise => {
- const metadata = await db.metadata.get(DATA_PRIMARY_KEY);
- if (metadata !== undefined) {
- return metadata;
- }
- return { id: DATA_PRIMARY_KEY, version: DEFAULT_VERSION };
+ const metadata = await db.metadata.get(DATA_PRIMARY_KEY);
+ if (metadata !== undefined) {
+ return metadata;
+ }
+ return { id: DATA_PRIMARY_KEY, version: DEFAULT_VERSION };
};
export const getAppData = async () => {
- const data = await database.appdata.get(DATA_PRIMARY_KEY);
- if (data === undefined) {
- // TODO: replace with better logging
- console.warn("warning, app data is undefined");
- }
- return data;
+ const data = await database.appdata.get(DATA_PRIMARY_KEY);
+ if (data === undefined) {
+ // TODO: replace with better logging
+ console.warn("warning, app data is undefined");
+ }
+ return data;
};
export const getQuestion = async (id: string) => {
- const localQuestion = await database.questions.get(id);
- return localQuestion;
+ const localQuestion = await database.questions.get(id);
+ return localQuestion;
};
diff --git a/src/hooks/useFilteredQuestions.ts b/src/hooks/useFilteredQuestions.ts
new file mode 100644
index 0000000..d2abcf8
--- /dev/null
+++ b/src/hooks/useFilteredQuestions.ts
@@ -0,0 +1,32 @@
+import { useLiveQuery } from "dexie-react-hooks";
+import { useMemo } from "react";
+import { database } from "@/database";
+import { applyFilters } from "@/lib/filter";
+import { applyHints } from "@/lib/highlight";
+import { type UseSearchResult, runMinisearch } from "@/lib/minisearch";
+import { applySort } from "@/lib/sort";
+import { useFilterStore } from "@/stores/filters";
+import { useSearchStore } from "@/stores/search";
+import { useSortStore } from "@/stores/sort";
+
+export const useFilteredQuestions = (): {
+ questions: UseSearchResult[];
+ loading: boolean;
+} => {
+ const dbQuestions = useLiveQuery(() => database.questions.toArray(), []);
+ const query = useSearchStore((s) => s.query);
+ const filters = useFilterStore((s) => s.filters);
+ const sort = useSortStore((s) => s.sort);
+
+ const searched = useMemo(
+ () => runMinisearch(query, dbQuestions),
+ [query, dbQuestions],
+ );
+ const filtered = useMemo(
+ () => applyFilters(searched, filters),
+ [searched, filters],
+ );
+ const highlighted = useMemo(() => applyHints(filtered), [filtered]);
+ const sorted = useMemo(() => applySort(highlighted, sort), [highlighted, sort]);
+ return { questions: sorted, loading: dbQuestions === undefined };
+};
diff --git a/src/hooks/useMinisearchLoader.ts b/src/hooks/useMinisearchLoader.ts
new file mode 100644
index 0000000..1d99f03
--- /dev/null
+++ b/src/hooks/useMinisearchLoader.ts
@@ -0,0 +1,4 @@
+import { useCallback } from "react";
+import { loadMinisearch } from "@/lib/minisearch";
+
+export const useMinisearchLoader = () => useCallback(loadMinisearch, []);
diff --git a/src/hooks/usePageTitle.ts b/src/hooks/usePageTitle.ts
new file mode 100644
index 0000000..22bd4c8
--- /dev/null
+++ b/src/hooks/usePageTitle.ts
@@ -0,0 +1,7 @@
+import { useEffect } from "react";
+
+export const usePageTitle = (title: string) => {
+ useEffect(() => {
+ document.title = `${title} | ${import.meta.env.VITE_APP_NAME}`;
+ }, [title]);
+};
\ No newline at end of file
diff --git a/src/lib/filter.ts b/src/lib/filter.ts
new file mode 100644
index 0000000..4f41802
--- /dev/null
+++ b/src/lib/filter.ts
@@ -0,0 +1,72 @@
+import type { Question } from "@qnaplus/scraper";
+import {
+ QuestionStateValue,
+ type SearchFilters,
+ isEmptyFilterValue,
+} from "@/stores/filters";
+import type { UseSearchResult } from "./minisearch";
+
+type FilterMap = {
+ [K in keyof SearchFilters]: (
+ question: Question,
+ filters: SearchFilters,
+ ) => boolean;
+};
+
+const FILTER_MAP: FilterMap = {
+ season(q, f) {
+ return f.season.find((s) => s.value === q.season) !== undefined;
+ },
+ program(q, f) {
+ return f.program.find((p) => p.value === q.program) !== undefined;
+ },
+ author(q, f) {
+ return q.author.includes(f.author ?? "");
+ },
+ state(q, f) {
+ if (f.state.value === QuestionStateValue.All) {
+ return true;
+ }
+ return f.state.value === QuestionStateValue.Answered
+ ? q.answered
+ : !q.answered;
+ },
+ askedBefore(q, f) {
+ if (q.askedTimestampMs === null || f.askedBefore === null) {
+ return false;
+ }
+ return new Date(q.askedTimestampMs) < f.askedBefore;
+ },
+ askedAfter(q, f) {
+ if (q.askedTimestampMs === null || f.askedAfter === null) {
+ return false;
+ }
+ return new Date(q.askedTimestampMs) > f.askedAfter;
+ },
+ answeredBefore(q, f) {
+ if (q.askedTimestampMs === null || f.answeredBefore === null) {
+ return false;
+ }
+ return new Date(q.askedTimestampMs) < f.answeredBefore;
+ },
+ answeredAfter(q, f) {
+ if (q.askedTimestampMs === null || f.answeredAfter === null) {
+ return false;
+ }
+ return new Date(q.askedTimestampMs) > f.answeredAfter;
+ },
+ tags(q, f) {
+ return f.tags.every((t) => q.tags.includes(t));
+ },
+};
+
+export const applyFilters = (
+ questions: UseSearchResult[],
+ filters: SearchFilters,
+): UseSearchResult[] => {
+ const keys = Object.keys(filters) as Array;
+ const applicableFilters = keys
+ .filter((k) => !isEmptyFilterValue(filters[k]))
+ .map((k) => FILTER_MAP[k]);
+ return questions.filter((q) => applicableFilters.every((f) => f(q, filters)));
+};
diff --git a/src/lib/highlight.ts b/src/lib/highlight.ts
new file mode 100644
index 0000000..63cbf69
--- /dev/null
+++ b/src/lib/highlight.ts
@@ -0,0 +1,26 @@
+import type { UseSearchResult } from "./minisearch";
+
+const HIGHLIGHT_FIELDS = ["title", "questionRaw", "answerRaw"] as const;
+
+export const applyHints = (results: UseSearchResult[]): UseSearchResult[] => {
+ return results.map((result) => {
+ if (!("terms" in result)) {
+ return result;
+ }
+ const highlighted = { ...result };
+ for (const term of result.terms) {
+ const matchedFields = result.match[term];
+ for (const field of matchedFields) {
+ if (!HIGHLIGHT_FIELDS.includes(field as (typeof HIGHLIGHT_FIELDS)[number])) {
+ continue;
+ }
+ if (field !== "title") {
+ highlighted[field as "questionRaw" | "answerRaw"] = (
+ highlighted[field as "questionRaw" | "answerRaw"] ?? ""
+ ).replace(new RegExp(`(${term})`, "gi"), "$1 ");
+ }
+ }
+ }
+ return highlighted;
+ });
+};
diff --git a/src/lib/minisearch.ts b/src/lib/minisearch.ts
new file mode 100644
index 0000000..0617492
--- /dev/null
+++ b/src/lib/minisearch.ts
@@ -0,0 +1,98 @@
+import type { Question } from "@qnaplus/scraper";
+import MiniSearch, { type SearchResult } from "minisearch";
+import { stemmer } from "stemmer";
+import { isEmpty } from "@/util/strings";
+import { cleanQuestionHtml } from "./sanitize";
+
+export type QuestionSearchResult = Question & SearchResult;
+
+export type UseSearchResult = Question | QuestionSearchResult;
+
+const HTML_TOKENIZER_REGEX = /(?:[\n\r\p{Z}\p{P}]|(?:<\/?\w+>))+/gu;
+
+export const minisearch = new MiniSearch({
+ fields: ["title", "author", "questionRaw", "answerRaw", "tags"],
+ storeFields: [
+ "id",
+ "url",
+ "author",
+ "program",
+ "title",
+ "question",
+ "questionRaw",
+ "answer",
+ "answerRaw",
+ "season",
+ "askedTimestamp",
+ "askedTimestampMs",
+ "answeredTimestamp",
+ "answeredTimestampMs",
+ "answered",
+ "tags",
+ ],
+ tokenize: (text) =>
+ text.split(HTML_TOKENIZER_REGEX).filter((t) => !isEmpty(t)),
+});
+
+let loaded = false;
+let cleanedQuestions: Question[] = [];
+
+const cleanQuestion = (q: Question): Question => ({
+ ...q,
+ questionRaw: cleanQuestionHtml(q.questionRaw),
+ answerRaw: q.answerRaw === null ? null : cleanQuestionHtml(q.answerRaw),
+});
+
+export const loadMinisearch = async (questions: Question[]): Promise => {
+ if (loaded) {
+ return;
+ }
+ cleanedQuestions = questions.map(cleanQuestion);
+ try {
+ await minisearch.addAllAsync(cleanedQuestions, { chunkSize: 50 });
+ loaded = true;
+ } catch (e) {
+ console.error(e);
+ }
+};
+
+export const runMinisearch = (
+ query: string,
+ dbQuestions: Question[] | undefined,
+): UseSearchResult[] => {
+ if (dbQuestions === undefined) {
+ return [];
+ }
+ if (isEmpty(query)) {
+ return cleanedQuestions;
+ }
+ return minisearch.search(query, {
+ fields: ["title", "questionRaw", "answerRaw"],
+ processTerm: (term) => stemmer(term).toLowerCase(),
+ prefix: true,
+ }) as QuestionSearchResult[];
+};
+
+export const getAuthorSuggestions = (author: string | null): string[] => {
+ if (author === null || author === "") {
+ return [];
+ }
+ const results = minisearch.search(author, {
+ fields: ["author"],
+ prefix: true,
+ }) as QuestionSearchResult[];
+ return [...new Set(results.map((r) => r.author))];
+};
+
+export const getTagSuggestions = (tag: string | undefined): string[] => {
+ if (tag === undefined || tag === "") {
+ return [];
+ }
+ const results = minisearch.search(tag, {
+ fields: ["tags"],
+ prefix: true,
+ }) as QuestionSearchResult[];
+ const isMatchingTag = (result: QuestionSearchResult) =>
+ result.tags.filter((t) => result.terms.includes(t.toLowerCase()));
+ return [...new Set(results.flatMap(isMatchingTag).sort())];
+};
diff --git a/src/lib/sanitize.ts b/src/lib/sanitize.ts
new file mode 100644
index 0000000..bc9c82b
--- /dev/null
+++ b/src/lib/sanitize.ts
@@ -0,0 +1,17 @@
+import sanitize from "sanitize-html";
+
+const sanitizeOptions: sanitize.IOptions = {
+ allowedTags: sanitize.defaults.allowedTags.concat(["img", "mark"]),
+ allowedAttributes: {
+ ...sanitize.defaults.allowedAttributes,
+ ol: ["start"],
+ img: ["src", "alt"],
+ },
+};
+
+export const cleanQuestionHtml = (raw: string | null | undefined): string => {
+ if (raw === null || raw === undefined || raw === "") {
+ return "";
+ }
+ return sanitize(raw, sanitizeOptions);
+};
diff --git a/src/lib/sort.ts b/src/lib/sort.ts
new file mode 100644
index 0000000..ff05eb6
--- /dev/null
+++ b/src/lib/sort.ts
@@ -0,0 +1,46 @@
+import type { Question } from "@qnaplus/scraper";
+import {
+ type SearchSortOptions,
+ SortOptions,
+ SortOrder,
+} from "@/stores/sort";
+import { type SortFunction, multisortrules } from "@/util/sorting";
+import type { UseSearchResult } from "./minisearch";
+
+type SortMap = {
+ [K in SortOptions]: SortFunction;
+};
+
+const SORT_MAP: SortMap = {
+ [SortOptions.Season]: (a, b) =>
+ Number.parseInt(b.season.split("-")[1]) -
+ Number.parseInt(a.season.split("-")[1]),
+ [SortOptions.Author]: (a, b) => b.author.localeCompare(a.author),
+ [SortOptions.QuestionState]: (a, b) => +b.answered - +a.answered,
+ [SortOptions.AskDate]: (a, b) =>
+ Number.parseInt(b.id) - Number.parseInt(a.id),
+ [SortOptions.AnswerDate]: (a, b) =>
+ Number.parseInt(b.id) -
+ Number.parseInt(a.id) +
+ (b.answeredTimestampMs ?? 0) -
+ (a.answeredTimestampMs ?? 0),
+};
+
+export const applySort = (
+ questions: UseSearchResult[],
+ sort: SearchSortOptions,
+): UseSearchResult[] => {
+ const copy = [...questions] as Question[];
+ if (sort.advancedEnabled) {
+ const rules = sort.advanced.map((s) => ({
+ sort: SORT_MAP[s.value],
+ asc: s.asc.value === SortOrder.Ascending,
+ }));
+ return multisortrules(copy, rules);
+ }
+ const fn = SORT_MAP[sort.basic.sort.value];
+ copy.sort((a, b) =>
+ sort.basic.asc.value === SortOrder.Ascending ? fn(b, a) : fn(a, b),
+ );
+ return copy;
+};
diff --git a/src/lib/truncate.ts b/src/lib/truncate.ts
new file mode 100644
index 0000000..4cec76b
--- /dev/null
+++ b/src/lib/truncate.ts
@@ -0,0 +1,56 @@
+/**
+ * Truncates an HTML string to a maximum text character length while keeping
+ * tags properly closed. This provides a better proxy for visual line count
+ * since block elements wrap based on content length.
+ */
+export function truncateHtml(html: string, maxChars: number): string {
+ let textLen = 0;
+ let result = "";
+ const openTags: string[] = [];
+ let i = 0;
+
+ while (i < html.length && textLen < maxChars) {
+ if (html[i] === "<") {
+ const tagEnd = html.indexOf(">", i);
+ if (tagEnd === -1) break;
+
+ const tag = html.slice(i, tagEnd + 1);
+ const tagNameMatch = tag.match(/^<\/?(\w+)/);
+ const tagName = tagNameMatch ? tagNameMatch[1].toLowerCase() : "";
+
+ result += tag;
+
+ if (tag.startsWith("")) {
+ openTags.pop();
+ } else if (!tag.endsWith("/>") && !isVoidTag(tagName)) {
+ openTags.push(tagName);
+ }
+
+ i = tagEnd + 1;
+ } else {
+ result += html[i];
+ textLen++;
+ i++;
+ }
+ }
+
+ if (openTags.length !== 0) {
+ result += " [...]";
+ }
+
+ // Close any remaining open tags
+ while (openTags.length > 0) {
+ result += `${openTags.pop()}>`;
+ }
+
+ return result;
+}
+
+const VOID_TAGS = new Set([
+ "area", "base", "br", "col", "embed", "hr", "img",
+ "input", "link", "meta", "source", "track", "wbr",
+]);
+
+function isVoidTag(tagName: string): boolean {
+ return VOID_TAGS.has(tagName);
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..bd0c391
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/src/main.ts b/src/main.ts
deleted file mode 100644
index a6f4b13..0000000
--- a/src/main.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { useRegisterSW } from "virtual:pwa-register/vue";
-import { definePreset } from "@primeuix/themes";
-import Aura from "@primeuix/themes/aura";
-import PrimeVue from "primevue/config";
-import { createApp } from "vue";
-import App from "./App.vue";
-import router from "./router";
-
-useRegisterSW({ immediate: true });
-
-const primary = import.meta.env.MODE === "development" ? "teal" : "blue";
-
-const preset = definePreset(Aura, {
- semantic: {
- primary: {
- 50: `{${primary}.50}`,
- 100: `{${primary}.100}`,
- 200: `{${primary}.200}`,
- 300: `{${primary}.300}`,
- 400: `{${primary}.400}`,
- 500: `{${primary}.500}`,
- 600: `{${primary}.600}`,
- 700: `{${primary}.700}`,
- 800: `{${primary}.800}`,
- 900: `{${primary}.900}`,
- 950: `{${primary}.950}`,
- },
- colorScheme: {
- light: {
- surface: {
- 0: "#ffffff",
- 50: "{zinc.50}",
- 100: "{zinc.100}",
- 200: "{zinc.200}",
- 300: "{zinc.300}",
- 400: "{zinc.400}",
- 500: "{zinc.500}",
- 600: "{zinc.600}",
- 700: "{zinc.700}",
- 800: "{zinc.800}",
- 900: "{zinc.900}",
- 950: "{zinc.950}",
- },
- },
- dark: {
- surface: {
- 0: "#ffffff",
- 50: "{zinc.50}",
- 100: "{zinc.100}",
- 200: "{zinc.200}",
- 300: "{zinc.300}",
- 400: "{zinc.400}",
- 500: "{zinc.500}",
- 600: "{zinc.600}",
- 700: "{zinc.700}",
- 800: "{zinc.800}",
- 900: "{zinc.900}",
- 950: "{zinc.950}",
- },
- },
- },
- },
-});
-
-createApp(App)
- .use(PrimeVue, {
- // Default theme configuration
- theme: {
- preset,
- options: {
- prefix: "p",
- darkModeSelector: ".dark",
- },
- },
- })
- .use(router)
- .mount("#app");
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..4028d40
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,30 @@
+import "./theme/globals.css";
+import "./theme/style.css";
+
+import { ThemeProvider } from "next-themes";
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { registerSW } from "virtual:pwa-register";
+import App from "./App.tsx";
+import { TooltipProvider } from "./components/ui/tooltip";
+
+registerSW({ immediate: true });
+
+if (import.meta.env.MODE === "development") {
+ document.documentElement.dataset.mode = "dev";
+}
+
+const container = document.getElementById("root");
+if (container === null) {
+ throw new Error("Root container #root not found");
+}
+
+createRoot(container).render(
+
+
+
+
+
+
+ ,
+);
diff --git a/src/pages/QuestionPage.tsx b/src/pages/QuestionPage.tsx
new file mode 100644
index 0000000..92bb6df
--- /dev/null
+++ b/src/pages/QuestionPage.tsx
@@ -0,0 +1,51 @@
+import Root from "@/components/layout/Root";
+import QuestionView from "@/components/question/QuestionView";
+import { getQuestion } from "@/database";
+import type { Question } from "@qnaplus/scraper";
+import { useEffect, useState } from "react";
+import { useRoute } from "wouter";
+import { usePageTitle } from "../hooks/usePageTitle";
+
+export default function QuestionPage() {
+ const [, params] = useRoute("/:id");
+ const id = params?.id;
+
+ const [question, setQuestion] = useState(undefined);
+ const title = question?.title ?? id ?? "View Question";
+ usePageTitle(title);
+
+ useEffect(() => {
+ if (id === undefined) return;
+ let cancelled = false;
+ void (async () => {
+ const q = await getQuestion(id);
+ if (!cancelled) {
+ setQuestion(q ?? null);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [id]);
+
+ return (
+
+
+ {question === undefined ? (
+ <>>
+ ) : (
+
+ {question === null ? (
+
+
uhhhhhhhhhh...
+ Couldn't find a question here.
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx
new file mode 100644
index 0000000..0ca59d3
--- /dev/null
+++ b/src/pages/SearchPage.tsx
@@ -0,0 +1,74 @@
+import Root from "@/components/layout/Root";
+import FilterPanel from "@/components/search/FilterPanel";
+import NoResults from "@/components/search/NoResults";
+import QuestionDrawer from "@/components/search/QuestionDrawer";
+import QuestionList from "@/components/search/QuestionList";
+import QuestionListHeader from "@/components/search/QuestionListHeader";
+import ScrollToTop from "@/components/search/ScrollToTop";
+import SearchInput from "@/components/search/SearchInput";
+import SearchOptions from "@/components/search/SearchOptions";
+import BasicSort from "@/components/search/filters/BasicSort";
+import SortOrderToggle from "@/components/search/filters/SortOrderToggle";
+import { Button } from "@/components/ui/button";
+import { useFilteredQuestions } from "@/hooks/useFilteredQuestions";
+import type { Question } from "@qnaplus/scraper";
+import { IconFilter } from "@tabler/icons-react";
+import { useState } from "react";
+import { usePageTitle } from "../hooks/usePageTitle";
+
+export default function SearchPage() {
+ const { questions, loading } = useFilteredQuestions();
+ const [filtersOpen, setFiltersOpen] = useState(false);
+ usePageTitle("Search");
+
+ return (
+
+
+
+
+
+
+
+ setFiltersOpen((o) => !o)}
+ aria-label="Toggle filters"
+ className="md:hidden"
+ >
+
+
+
+
+
+
+ {loading ? (
+ <>>
+ ) : (
+
+ {questions.length === 0 ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/rendering.ts b/src/rendering.ts
deleted file mode 100644
index 194e9ab..0000000
--- a/src/rendering.ts
+++ /dev/null
@@ -1,227 +0,0 @@
-import type { Question } from "@qnaplus/scraper";
-import {
- type Node as ParserNode,
- Text as TextNode,
- isTag,
- isText,
-} from "domhandler";
-import { parseDocument } from "htmlparser2";
-import sanitize from "sanitize-html";
-import Blockquote from "./components/question/Blockquote.vue";
-import Code from "./components/question/Code.vue";
-import Emphasis from "./components/question/Emphasis.vue";
-import Header from "./components/question/Header.vue";
-import Image from "./components/question/Image.vue";
-import Link from "./components/question/Link.vue";
-import ListItem from "./components/question/ListItem.vue";
-import Mark from "./components/question/Mark.vue";
-import OrderedList from "./components/question/OrderedList.vue";
-import Paragraph from "./components/question/Paragraph.vue";
-import Pre from "./components/question/Pre.vue";
-import Strong from "./components/question/Strong.vue";
-import Text from "./components/question/Text.vue";
-import UnorderedList from "./components/question/UnorderedList.vue";
-
-const HEADER_REGEX = /h[1-6]/;
-
-export type QuestionComponentNode = ReturnType<
- typeof resolveQuestionComponentNode
->;
-
-export const resolveQuestionComponentNode = (node: ParserNode) => {
- switch (true) {
- case isTag(node) && node.name === "img":
- return Image;
- case isTag(node) && node.name === "p":
- return Paragraph;
- case isTag(node) && node.name === "a":
- return Link;
- case isTag(node) && node.name === "strong":
- return Strong;
- case isTag(node) && node.name === "em":
- return Emphasis;
- case isTag(node) && node.name === "br":
- return "br";
- case isTag(node) && node.name === "blockquote":
- return Blockquote;
- case isTag(node) && node.name === "ol":
- return OrderedList;
- case isTag(node) && node.name === "ul":
- return UnorderedList;
- case isTag(node) && node.name === "li":
- return ListItem;
- case isTag(node) && node.name === "code":
- return Code;
- case isTag(node) && node.name === "pre":
- return Pre;
- case isTag(node) && node.name === "mark":
- return Mark;
- case isTag(node) && HEADER_REGEX.test(node.name):
- return Header;
- case node instanceof TextNode:
- return Text;
- }
-};
-
-export type QuestionComponentProps = ReturnType<
- typeof resolveQuestionComponentProps
->;
-
-export const resolveQuestionComponentProps = (node: ParserNode) => {
- if (isTag(node) && node.name === "img") {
- return { src: node.attribs.src, height: 250, preview: true };
- }
- if (
- isTag(node) &&
- [
- "em",
- "p",
- "strong",
- "blockquote",
- "li",
- "pre",
- "code",
- "ul",
- "mark",
- ].includes(node.name)
- ) {
- return { children: node.children };
- }
- if (isTag(node) && HEADER_REGEX.test(node.name)) {
- return { header: node.name, children: node.children };
- }
- if (isTag(node) && node.name === "a") {
- return { href: node.attribs.href, children: node.children };
- }
- if (isTag(node) && node.name === "ol") {
- return {
- start: Number.parseInt(node.attribs.start ?? "1"),
- children: node.children,
- };
- }
- if (isText(node)) {
- return { text: node.data };
- }
- return {};
-};
-
-export type QuestionComponentSize = ReturnType<
- typeof resolveQuestionComponentSize
->;
-
-export const resolveQuestionComponentSize = (node: ParserNode): number => {
- if (isTag(node) && node.name === "img") {
- return 250;
- }
- if (
- isTag(node) &&
- [
- "em",
- "p",
- "strong",
- "blockquote",
- "ol",
- "li",
- "a",
- "pre",
- "code",
- "ul",
- "mark",
- ].includes(node.name)
- ) {
- return node.children.reduce(
- (size, child) => size + resolveQuestionComponentSize(child),
- 0,
- );
- }
- if (isTag(node) && HEADER_REGEX.test(node.name)) {
- return node.children.reduce(
- (size, child) => size + resolveQuestionComponentSize(child),
- 0,
- );
- }
- if (isText(node)) {
- return node.data.split(" ").length;
- }
- return 0;
-};
-
-export interface ResolvedComponent {
- node: QuestionComponentNode;
- props: QuestionComponentProps;
- size: QuestionComponentSize;
-}
-
-export const resolveQuestionComponent = (
- node: ParserNode,
-): ResolvedComponent => {
- return {
- node: resolveQuestionComponentNode(node),
- props: resolveQuestionComponentProps(node),
- size: resolveQuestionComponentSize(node),
- };
-};
-
-export interface RenderQuestionOptions {
- limit?: number;
-}
-
-export const renderQuestion = (
- question: Question | undefined,
- opts?: RenderQuestionOptions,
-) => {
- if (question === undefined) {
- return {
- questionContent: [],
- answerContent: [],
- };
- }
- const limit = opts?.limit ?? Number.POSITIVE_INFINITY;
-
- const allowedAttributes = {
- ...sanitize.defaults.allowedAttributes,
- ol: ["start"],
- };
- const sanitizeOptions: sanitize.IOptions = {
- allowedTags: sanitize.defaults.allowedTags.concat("img"),
- allowedAttributes,
- };
-
- const sanitizedQuestionHTML = sanitize(question.questionRaw, sanitizeOptions);
- const questionDom = parseDocument(sanitizedQuestionHTML);
- const questionChildren = questionDom.children as ParserNode[];
-
- const sanitizedAnswerHTML = sanitize(
- question.answerRaw ?? "",
- sanitizeOptions,
- );
- const answerDom = parseDocument(sanitizedAnswerHTML);
- const answerChildren = answerDom.children as ParserNode[];
-
- let truncated = false;
- let questionSize = 0;
- const questionContent: ResolvedComponent[] = [];
- for (const node of questionChildren) {
- if (questionSize >= limit) {
- truncated = true;
- break;
- }
- const component = resolveQuestionComponent(node);
- questionContent.push(component);
- questionSize += component.size;
- }
-
- let answerSize = 0;
- const answerContent: ResolvedComponent[] = [];
- for (const node of answerChildren) {
- if (truncated || answerSize >= limit) {
- truncated = true;
- break;
- }
- const component = resolveQuestionComponent(node);
- answerContent.push(component);
- answerSize += component.size;
- }
-
- return { questionContent, answerContent };
-};
diff --git a/src/router/Question.vue b/src/router/Question.vue
deleted file mode 100644
index b155a07..0000000
--- a/src/router/Question.vue
+++ /dev/null
@@ -1,74 +0,0 @@
-
-
-
-
-
-
-
-
uhhhhhhhhhh...
- Couldn't find a question here.
-
-
-
- qnaplus is an unofficial third-party application. Visit the Q&A on
- RobotEvents to get the most up-to-date information.
-
-
{{ question.title }}
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/router/Root.vue b/src/router/Root.vue
deleted file mode 100644
index a4f127f..0000000
--- a/src/router/Root.vue
+++ /dev/null
@@ -1,38 +0,0 @@
-
-
-
-
-
-
-
diff --git a/src/router/Search.vue b/src/router/Search.vue
deleted file mode 100644
index d13e1b5..0000000
--- a/src/router/Search.vue
+++ /dev/null
@@ -1,67 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
- selectedQuestion = q" :query="query"
- :questions="sortedQuestions" />
-
-
-
- selectedQuestion = undefined" :question="selectedQuestion" />
-
-
-
-
\ No newline at end of file
diff --git a/src/router/index.ts b/src/router/index.ts
deleted file mode 100644
index ea504ee..0000000
--- a/src/router/index.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import {
- type RouteRecordRaw,
- createRouter,
- createWebHashHistory,
-} from "vue-router";
-
-import Question from "./Question.vue";
-import Search from "./Search.vue";
-
-const routes: RouteRecordRaw[] = [
- {
- path: "/",
- component: Search,
- },
- {
- path: "/:id",
- component: Question,
- props: true,
- },
-];
-
-export default createRouter({
- history: createWebHashHistory(),
- routes,
- scrollBehavior(_to, _from, savedPosition) {
- if (savedPosition) {
- return new Promise((resolve) => {
- setTimeout(() => {
- resolve({ left: 0, top: savedPosition.top, behavior: "instant" });
- }, 200);
- });
- }
- },
-});
diff --git a/src/stores/appData.ts b/src/stores/appData.ts
new file mode 100644
index 0000000..f49d08c
--- /dev/null
+++ b/src/stores/appData.ts
@@ -0,0 +1,16 @@
+import { create } from "zustand";
+
+type AppData = {
+ seasons: string[];
+ programs: string[];
+};
+
+type AppDataStore = AppData & {
+ setAppData: (data: AppData) => void;
+};
+
+export const useAppDataStore = create((set) => ({
+ seasons: [],
+ programs: [],
+ setAppData: (data) => set(data),
+}));
diff --git a/src/stores/filters.ts b/src/stores/filters.ts
new file mode 100644
index 0000000..a0c743a
--- /dev/null
+++ b/src/stores/filters.ts
@@ -0,0 +1,78 @@
+import type { Question } from "@qnaplus/scraper";
+import { create } from "zustand";
+import type { Option } from "./types";
+
+export enum QuestionStateValue {
+ All = 0,
+ Answered = 1,
+ Unanswered = 2,
+}
+
+export const questionStateOptions: Option[] = [
+ { name: "All", value: QuestionStateValue.All },
+ { name: "Answered", value: QuestionStateValue.Answered },
+ { name: "Unanswered", value: QuestionStateValue.Unanswered },
+];
+
+export type SearchFilters = {
+ season: Option[];
+ program: Option[];
+ author: Question["author"] | null;
+ state: Option;
+ askedBefore: Date | null;
+ askedAfter: Date | null;
+ answeredBefore: Date | null;
+ answeredAfter: Date | null;
+ tags: string[];
+};
+
+const getInitialFilters = (): SearchFilters => ({
+ season: [],
+ program: [],
+ author: null,
+ state: questionStateOptions[0],
+ askedBefore: null,
+ askedAfter: null,
+ answeredBefore: null,
+ answeredAfter: null,
+ tags: [],
+});
+
+type FilterStore = {
+ filters: SearchFilters;
+ defaultFilters: SearchFilters;
+ setFilter: (key: K, value: SearchFilters[K]) => void;
+ setDefaultFilters: (defaults: Partial) => void;
+ clearFilters: () => void;
+};
+
+export const useFilterStore = create((set) => ({
+ filters: getInitialFilters(),
+ defaultFilters: getInitialFilters(),
+ setFilter: (key, value) =>
+ set((s) => ({ filters: { ...s.filters, [key]: value } })),
+ setDefaultFilters: (defaults) =>
+ set(() => {
+ const defaultFilters = { ...getInitialFilters(), ...defaults };
+ return { defaultFilters, filters: defaultFilters };
+ }),
+ clearFilters: () => set((s) => ({ filters: s.defaultFilters })),
+}));
+
+export const isEmptyFilterValue = (
+ filterValue: SearchFilters[keyof SearchFilters],
+): boolean => {
+ if (typeof filterValue === "string") {
+ return filterValue.trim() === "";
+ }
+ if (Array.isArray(filterValue)) {
+ return filterValue.length === 0;
+ }
+ return filterValue === null;
+};
+
+export const selectAppliedFilterCount = (filters: SearchFilters): number => {
+ const keys = Object.keys(filters) as Array;
+ const applied = keys.filter((k) => !isEmptyFilterValue(filters[k]));
+ return applied.length - (filters.state.value === QuestionStateValue.All ? 1 : 0);
+};
diff --git a/src/stores/search.ts b/src/stores/search.ts
new file mode 100644
index 0000000..6ae7c4c
--- /dev/null
+++ b/src/stores/search.ts
@@ -0,0 +1,18 @@
+import type { Question } from "@qnaplus/scraper";
+import { create } from "zustand";
+
+type SearchStore = {
+ query: string;
+ selectedQuestion: Question | undefined;
+ setQuery: (query: string) => void;
+ openQuestion: (question: Question) => void;
+ closeDrawer: () => void;
+};
+
+export const useSearchStore = create((set) => ({
+ query: "",
+ selectedQuestion: undefined,
+ setQuery: (query) => set({ query }),
+ openQuestion: (question) => set({ selectedQuestion: question }),
+ closeDrawer: () => set({ selectedQuestion: undefined }),
+}));
diff --git a/src/stores/sort.ts b/src/stores/sort.ts
new file mode 100644
index 0000000..7f7374d
--- /dev/null
+++ b/src/stores/sort.ts
@@ -0,0 +1,101 @@
+import { create } from "zustand";
+import type { Option } from "./types";
+
+export enum SortOptions {
+ Season = 0,
+ Author = 1,
+ QuestionState = 2,
+ AskDate = 3,
+ AnswerDate = 4,
+}
+
+export enum SortOrder {
+ Ascending = 0,
+ Descending = 1,
+}
+
+export type AdvancedSortOption = Option & {
+ asc: Option;
+};
+
+export type SearchSortOptions = {
+ basic: {
+ sort: Option;
+ asc: Option;
+ };
+ advanced: AdvancedSortOption[];
+ advancedEnabled: boolean;
+};
+
+export const sortOrderList: Option[] = [
+ { name: "Ascending", value: SortOrder.Ascending },
+ { name: "Descending", value: SortOrder.Descending },
+];
+
+export const sortOptionsList: Option[] = [
+ { name: "Ask Date", value: SortOptions.AskDate },
+ { name: "Answer Date", value: SortOptions.AnswerDate },
+ { name: "Season", value: SortOptions.Season },
+ { name: "Author", value: SortOptions.Author },
+ { name: "Question State", value: SortOptions.QuestionState },
+];
+
+const getInitialSort = (): SearchSortOptions => ({
+ basic: {
+ sort: { name: "Ask Date", value: SortOptions.AskDate },
+ asc: { name: "Descending", value: SortOrder.Descending },
+ },
+ advanced: [],
+ advancedEnabled: false,
+});
+
+type SortStore = {
+ sort: SearchSortOptions;
+ setBasicSort: (sort: Option) => void;
+ setBasicAsc: (asc: Option) => void;
+ toggleAdvanced: (enabled: boolean) => void;
+ addAdvanced: (option: Option) => void;
+ removeAdvanced: (index: number) => void;
+ setAdvancedAsc: (index: number, asc: Option) => void;
+ reorderAdvanced: (from: number, to: number) => void;
+};
+
+export const useSortStore = create((set) => ({
+ sort: getInitialSort(),
+ setBasicSort: (sortOption) =>
+ set((s) => ({ sort: { ...s.sort, basic: { ...s.sort.basic, sort: sortOption } } })),
+ setBasicAsc: (asc) =>
+ set((s) => ({ sort: { ...s.sort, basic: { ...s.sort.basic, asc } } })),
+ toggleAdvanced: (advancedEnabled) =>
+ set((s) => ({ sort: { ...s.sort, advancedEnabled } })),
+ addAdvanced: (option) =>
+ set((s) => ({
+ sort: {
+ ...s.sort,
+ advanced: [...s.sort.advanced, { ...option, asc: sortOrderList[0] }],
+ },
+ })),
+ removeAdvanced: (index) =>
+ set((s) => ({
+ sort: {
+ ...s.sort,
+ advanced: s.sort.advanced.filter((_, i) => i !== index),
+ },
+ })),
+ setAdvancedAsc: (index, asc) =>
+ set((s) => ({
+ sort: {
+ ...s.sort,
+ advanced: s.sort.advanced.map((opt, i) =>
+ i === index ? { ...opt, asc } : opt,
+ ),
+ },
+ })),
+ reorderAdvanced: (from, to) =>
+ set((s) => {
+ const next = [...s.sort.advanced];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+ return { sort: { ...s.sort, advanced: next } };
+ }),
+}));
diff --git a/src/composable/types.ts b/src/stores/types.ts
similarity index 100%
rename from src/composable/types.ts
rename to src/stores/types.ts
diff --git a/src/styles.css b/src/styles.css
deleted file mode 100644
index 5b357dc..0000000
--- a/src/styles.css
+++ /dev/null
@@ -1,36 +0,0 @@
-@import "primeicons/primeicons.css";
-@import "tailwindcss";
-@import "tailwindcss-primeui";
-@plugin "@tailwindcss/typography";
-
-html,
-body {
- width: 100%;
- margin: 0;
- padding: 0;
-}
-
-@layer base {
- a:hover {
- @apply text-muted-color-emphasis;
- }
-}
-
-/* for SearchFilters.vue */
-.select-button-flex > button {
- flex-grow: 1 !important;
-}
-
-.h-screen-mobile {
- height: 100vh;
- height: 100svh;
-}
-
-.autocomplete-group ul {
- border-top-left-radius: 0;
- border-bottom-left-radius: 0;
-}
-
-.border-1 {
- border-width: 1px;
-}
diff --git a/src/theme/globals.css b/src/theme/globals.css
new file mode 100644
index 0000000..0c31c19
--- /dev/null
+++ b/src/theme/globals.css
@@ -0,0 +1,165 @@
+@import "tailwindcss";
+@plugin "@tailwindcss/typography";
+@import "tw-animate-css";
+@import "shadcn/tailwind.css";
+@import "@fontsource-variable/geist";
+
+@custom-variant dark (&:is(.dark *));
+
+:root {
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.141 0.005 285.823);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.141 0.005 285.823);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.141 0.005 285.823);
+ --primary: oklch(0.52 0.105 223.128);
+ --primary-foreground: oklch(0.984 0.019 200.873);
+ --secondary: oklch(0.967 0.001 286.375);
+ --secondary-foreground: oklch(0.21 0.006 285.885);
+ --muted: oklch(0.967 0.001 286.375);
+ --muted-foreground: oklch(0.552 0.016 285.938);
+ --accent: oklch(0.967 0.001 286.375);
+ --accent-foreground: oklch(0.21 0.006 285.885);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.92 0.004 286.32);
+ --input: oklch(0.92 0.004 286.32);
+ --ring: oklch(0.705 0.015 286.067);
+ --chart-1: oklch(0.871 0.006 286.286);
+ --chart-2: oklch(0.552 0.016 285.938);
+ --chart-3: oklch(0.442 0.017 285.786);
+ --chart-4: oklch(0.37 0.013 285.805);
+ --chart-5: oklch(0.274 0.006 286.033);
+ --radius: 0rem;
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.141 0.005 285.823);
+ --sidebar-primary: oklch(0.609 0.126 221.723);
+ --sidebar-primary-foreground: oklch(0.984 0.019 200.873);
+ --sidebar-accent: oklch(0.967 0.001 286.375);
+ --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
+ --sidebar-border: oklch(0.92 0.004 286.32);
+ --sidebar-ring: oklch(0.705 0.015 286.067);
+}
+
+.dark {
+ --background: oklch(0.17 0.01 285.49);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.21 0.006 285.885);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.21 0.006 285.885);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.45 0.085 224.283);
+ --primary-foreground: oklch(0.984 0.019 200.873);
+ --secondary: oklch(0.274 0.006 286.033);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.274 0.006 286.033);
+ --muted-foreground: oklch(0.705 0.015 286.067);
+ --accent: oklch(0.274 0.006 286.033);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.552 0.016 285.938);
+ --chart-1: oklch(0.871 0.006 286.286);
+ --chart-2: oklch(0.552 0.016 285.938);
+ --chart-3: oklch(0.442 0.017 285.786);
+ --chart-4: oklch(0.37 0.013 285.805);
+ --chart-5: oklch(0.274 0.006 286.033);
+ --sidebar: oklch(0.21 0.006 285.885);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.715 0.143 215.221);
+ --sidebar-primary-foreground: oklch(0.302 0.056 229.695);
+ --sidebar-accent: oklch(0.274 0.006 286.033);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.552 0.016 285.938);
+}
+
+:root[data-mode="dev"] {
+ --primary: oklch(0.7227 0.1485 195.07);
+ --primary-foreground: oklch(0.9911 0 0);
+ --ring: oklch(0.7227 0.1485 195.07);
+}
+
+@theme inline {
+ --font-sans: 'Geist Variable', sans-serif;
+ --font-heading: var(--font-sans);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar: var(--sidebar);
+ --color-chart-5: var(--chart-5);
+ --color-chart-4: var(--chart-4);
+ --color-chart-3: var(--chart-3);
+ --color-chart-2: var(--chart-2);
+ --color-chart-1: var(--chart-1);
+ --color-ring: var(--ring);
+ --color-input: var(--input);
+ --color-border: var(--border);
+ --color-destructive: var(--destructive);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-secondary: var(--secondary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary: var(--primary);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --color-foreground: var(--foreground);
+ --color-background: var(--background);
+ --radius-sm: calc(var(--radius) * 0.6);
+ --radius-md: calc(var(--radius) * 0.8);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) * 1.4);
+ --radius-2xl: calc(var(--radius) * 1.8);
+ --radius-3xl: calc(var(--radius) * 2.2);
+ --radius-4xl: calc(var(--radius) * 2.6);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ scrollbar-width: auto;
+ scrollbar-color: var(--border) transparent;
+ }
+
+ .dark * {
+ scrollbar-color: oklch(1 0 0 / 15%) transparent;
+ }
+
+ body {
+ @apply bg-background text-foreground;
+ background-attachment: fixed;
+ background-image: repeating-linear-gradient(
+ -45deg,
+ transparent,
+ transparent 20px,
+ color-mix(in oklab, var(--foreground) 3%, transparent) 20px,
+ color-mix(in oklab, var(--foreground) 3%, transparent) 21px
+ );
+ }
+
+ button:not(:disabled),
+ [role="button"]:not(:disabled) {
+ cursor: pointer;
+ }
+
+ html {
+ @apply font-sans;
+ }
+}
+
+@layer utilities {
+ .prose :where(blockquote):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
+ font-weight: normal;
+ font-style: normal;
+ }
+}
diff --git a/src/theme/style.css b/src/theme/style.css
new file mode 100644
index 0000000..69bfdb9
--- /dev/null
+++ b/src/theme/style.css
@@ -0,0 +1,17 @@
+
+.h-screen-mobile {
+ height: 100vh;
+ height: 100svh;
+}
+
+html,
+body,
+#root {
+ margin: 0;
+ padding: 0;
+ width: 100%;
+}
+
+#root {
+ min-height: 100svh;
+}
\ No newline at end of file
diff --git a/src/util/try.ts b/src/util/try.ts
new file mode 100644
index 0000000..0516ea1
--- /dev/null
+++ b/src/util/try.ts
@@ -0,0 +1,12 @@
+export type TrySuccess = [null, T];
+export type TryFailure = [E, null];
+export type TryResult = TrySuccess | TryFailure;
+
+export async function trycatch(promise: () => Promise): Promise> {
+ try {
+ const result = await promise();
+ return [null, result];
+ } catch (e) {
+ return [e as Error, null];
+ }
+};
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index 0bf4ab9..5d8af32 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -1,5 +1,6 @@
///
-///
+///
+///
interface ImportMetaEnv {
readonly VITE_APP_NAME: string;
diff --git a/tailwind.config.js b/tailwind.config.js
deleted file mode 100644
index 9c5057c..0000000
--- a/tailwind.config.js
+++ /dev/null
@@ -1,12 +0,0 @@
-/** @type {import('tailwindcss').Config} */
-export default {
- content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx,vue}"],
- theme: {
- extend: {},
- },
- plugins: [
- require("tailwindcss-primeui"),
- require("@tailwindcss/typography"),
- require("tailwind-gradient-mask-image"),
- ],
-};
diff --git a/tsconfig.json b/tsconfig.json
index 404b8cc..ccdbcbf 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,25 +1,37 @@
{
- "compilerOptions": {
- "target": "ES2020",
- "useDefineForClassFields": true,
- "module": "ESNext",
- "lib": ["ES2020", "DOM", "DOM.Iterable"],
- "skipLibCheck": true,
-
- /* Bundler mode */
- "moduleResolution": "bundler",
- "allowImportingTsExtensions": true,
- "resolveJsonModule": true,
- "isolatedModules": true,
- "noEmit": true,
- "jsx": "preserve",
-
- /* Linting */
- "strict": true,
- // "noUnusedLocals": true,
- // "noUnusedParameters": true,
- "noFallthroughCasesInSwitch": true
- },
- "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
- "references": [{ "path": "./tsconfig.node.json" }]
-}
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": [
+ "ES2020",
+ "DOM",
+ "DOM.Iterable"
+ ],
+ "skipLibCheck": true,
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ /* Linting */
+ "strict": true,
+ "noFallthroughCasesInSwitch": true,
+ "paths": {
+ "@/*": [
+ "./src/*"
+ ]
+ }
+ },
+ "include": [
+ "src/**/*.ts",
+ "src/**/*.tsx"
+ ],
+ "references": [
+ {
+ "path": "./tsconfig.node.json"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/vite.config.ts b/vite.config.ts
index e6922cf..fa9568b 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,17 +1,20 @@
-import { PrimeVueResolver } from "@primevue/auto-import-resolver";
+import path from "node:path";
import tailwindcss from "@tailwindcss/vite";
-import vue from "@vitejs/plugin-vue";
-import Components from "unplugin-vue-components/vite";
+import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
-// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
return {
base: "./",
publicDir: `public/${mode}`,
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
plugins: [
- vue(),
+ react(),
tailwindcss(),
VitePWA({
registerType: "autoUpdate",
@@ -65,10 +68,6 @@ export default defineConfig(({ mode }) => {
suppressWarnings: true,
},
}),
- Components({
- dts: true,
- resolvers: [PrimeVueResolver()],
- }),
],
build: {
rollupOptions: {