summaryrefslogtreecommitdiff
path: root/apps/web/src/components/ui
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/ui')
-rw-r--r--apps/web/src/components/ui/accordion.tsx114
-rw-r--r--apps/web/src/components/ui/alert.tsx142
-rw-r--r--apps/web/src/components/ui/avatar.tsx100
-rw-r--r--apps/web/src/components/ui/breadcrumb.tsx212
-rw-r--r--apps/web/src/components/ui/button.tsx126
-rw-r--r--apps/web/src/components/ui/calendar.tsx144
-rw-r--r--apps/web/src/components/ui/checkbox.tsx60
-rw-r--r--apps/web/src/components/ui/color-picker.tsx938
-rw-r--r--apps/web/src/components/ui/context-menu.tsx606
-rw-r--r--apps/web/src/components/ui/dialog.tsx290
-rw-r--r--apps/web/src/components/ui/dropdown-menu.tsx586
-rw-r--r--apps/web/src/components/ui/font-picker.tsx516
-rw-r--r--apps/web/src/components/ui/input.tsx286
-rw-r--r--apps/web/src/components/ui/number-field.tsx636
-rw-r--r--apps/web/src/components/ui/popover.tsx104
-rw-r--r--apps/web/src/components/ui/prose.tsx222
-rw-r--r--apps/web/src/components/ui/select.tsx442
-rw-r--r--apps/web/src/components/ui/sheet.tsx314
-rw-r--r--apps/web/src/components/ui/slider.tsx60
-rw-r--r--apps/web/src/components/ui/sonner.tsx70
-rw-r--r--apps/web/src/components/ui/spinner.tsx34
-rw-r--r--apps/web/src/components/ui/split-button.tsx180
-rw-r--r--apps/web/src/components/ui/tabs.tsx170
-rw-r--r--apps/web/src/components/ui/textarea.tsx48
-rw-r--r--apps/web/src/components/ui/tooltip.tsx152
-rw-r--r--apps/web/src/components/ui/use-overlay-open-change.ts156
26 files changed, 3354 insertions, 3354 deletions
diff --git a/apps/web/src/components/ui/accordion.tsx b/apps/web/src/components/ui/accordion.tsx
index 95a1c2df..772f38ad 100644
--- a/apps/web/src/components/ui/accordion.tsx
+++ b/apps/web/src/components/ui/accordion.tsx
@@ -1,57 +1,57 @@
-"use client";
-
-import * as React from "react";
-import { Accordion as AccordionPrimitive } from "radix-ui";
-import { ChevronDown } from "lucide-react";
-
-import { cn } from "@/utils/ui";
-
-const Accordion = AccordionPrimitive.Root;
-
-const AccordionItem = React.forwardRef<
- React.ElementRef<typeof AccordionPrimitive.Item>,
- React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
->(({ className, ...props }, ref) => (
- <AccordionPrimitive.Item
- ref={ref}
- className={cn("border-b", className)}
- {...props}
- />
-));
-AccordionItem.displayName = "AccordionItem";
-
-const AccordionTrigger = React.forwardRef<
- React.ElementRef<typeof AccordionPrimitive.Trigger>,
- React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
->(({ className, children, ...props }, ref) => (
- <AccordionPrimitive.Header className="flex">
- <AccordionPrimitive.Trigger
- ref={ref}
- className={cn(
- "flex flex-1 cursor-pointer items-center justify-between py-4 text-left text-sm font-medium hover:underline [&[data-state=open]>svg]:rotate-180",
- className,
- )}
- {...props}
- >
- {children}
- <ChevronDown className="text-muted-foreground size-4 shrink-0" />
- </AccordionPrimitive.Trigger>
- </AccordionPrimitive.Header>
-));
-AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
-
-const AccordionContent = React.forwardRef<
- React.ElementRef<typeof AccordionPrimitive.Content>,
- React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
->(({ className, children, ...props }, ref) => (
- <AccordionPrimitive.Content
- ref={ref}
- className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
- {...props}
- >
- <div className={cn("pt-0 pb-4", className)}>{children}</div>
- </AccordionPrimitive.Content>
-));
-AccordionContent.displayName = AccordionPrimitive.Content.displayName;
-
-export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+"use client";
+
+import * as React from "react";
+import { Accordion as AccordionPrimitive } from "radix-ui";
+import { ChevronDown } from "lucide-react";
+
+import { cn } from "@/utils/ui";
+
+const Accordion = AccordionPrimitive.Root;
+
+const AccordionItem = React.forwardRef<
+ React.ElementRef<typeof AccordionPrimitive.Item>,
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
+>(({ className, ...props }, ref) => (
+ <AccordionPrimitive.Item
+ ref={ref}
+ className={cn("border-b", className)}
+ {...props}
+ />
+));
+AccordionItem.displayName = "AccordionItem";
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef<typeof AccordionPrimitive.Trigger>,
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
+>(({ className, children, ...props }, ref) => (
+ <AccordionPrimitive.Header className="flex">
+ <AccordionPrimitive.Trigger
+ ref={ref}
+ className={cn(
+ "flex flex-1 cursor-pointer items-center justify-between py-4 text-left text-sm font-medium hover:underline [&[data-state=open]>svg]:rotate-180",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ <ChevronDown className="text-muted-foreground size-4 shrink-0" />
+ </AccordionPrimitive.Trigger>
+ </AccordionPrimitive.Header>
+));
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef<typeof AccordionPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
+>(({ className, children, ...props }, ref) => (
+ <AccordionPrimitive.Content
+ ref={ref}
+ className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
+ {...props}
+ >
+ <div className={cn("pt-0 pb-4", className)}>{children}</div>
+ </AccordionPrimitive.Content>
+));
+AccordionContent.displayName = AccordionPrimitive.Content.displayName;
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
diff --git a/apps/web/src/components/ui/alert.tsx b/apps/web/src/components/ui/alert.tsx
index dcc190d3..a1205cdd 100644
--- a/apps/web/src/components/ui/alert.tsx
+++ b/apps/web/src/components/ui/alert.tsx
@@ -1,71 +1,71 @@
-import * as React from "react";
-import { cva, type VariantProps } from "class-variance-authority";
-import { cn } from "@/utils/ui";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { Alert02Icon } from "@hugeicons/core-free-icons";
-
-const alertVariants = cva(
- "relative w-full rounded-lg border p-5 py-4.5 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-destructive [&>svg~*]:pl-7",
- {
- variants: {
- variant: {
- default: "bg-background text-foreground",
- destructive:
- "bg-destructive/5 border-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive",
- },
- },
- defaultVariants: {
- variant: "default",
- },
- },
-);
-
-const Alert = React.forwardRef<
- HTMLDivElement,
- React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
->(({ className, variant, children, ...props }, ref) => (
- <div
- ref={ref}
- role="alert"
- className={cn(alertVariants({ variant }), className)}
- {...props}
- >
- {variant === "destructive" && (
- <HugeiconsIcon
- icon={Alert02Icon}
- className="size-5 text-destructive mt-0.5"
- />
- )}
- {children}
- </div>
-));
-Alert.displayName = "Alert";
-
-const AlertTitle = React.forwardRef<
- HTMLParagraphElement,
- React.HTMLAttributes<HTMLHeadingElement>
->(({ className, ...props }, ref) => (
- <h5
- ref={ref}
- className={cn(
- "mb-2 text-base leading-none font-semibold tracking-tight",
- className,
- )}
- {...props}
- />
-));
-AlertTitle.displayName = "AlertTitle";
-
-const AlertDescription = React.forwardRef<
- HTMLParagraphElement,
- React.HTMLAttributes<HTMLParagraphElement>
->(({ className, ...props }, ref) => (
- <div
- ref={ref}
- className={cn("text-sm [&_p]:leading-relaxed", className)}
- {...props}
- />
-));
-AlertDescription.displayName = "AlertDescription";
-
-export { Alert, AlertTitle, AlertDescription };
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Alert02Icon } from "@hugeicons/core-free-icons";
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border p-5 py-4.5 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-destructive [&>svg~*]:pl-7",
+ {
+ variants: {
+ variant: {
+ default: "bg-background text-foreground",
+ destructive:
+ "bg-destructive/5 border-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+const Alert = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
+>(({ className, variant, children, ...props }, ref) => (
+ <div
+ ref={ref}
+ role="alert"
+ className={cn(alertVariants({ variant }), className)}
+ {...props}
+ >
+ {variant === "destructive" && (
+ <HugeiconsIcon
+ icon={Alert02Icon}
+ className="size-5 text-destructive mt-0.5"
+ />
+ )}
+ {children}
+ </div>
+));
+Alert.displayName = "Alert";
+
+const AlertTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes<HTMLHeadingElement>
+>(({ className, ...props }, ref) => (
+ <h5
+ ref={ref}
+ className={cn(
+ "mb-2 text-base leading-none font-semibold tracking-tight",
+ className,
+ )}
+ {...props}
+ />
+));
+AlertTitle.displayName = "AlertTitle";
+
+const AlertDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes<HTMLParagraphElement>
+>(({ className, ...props }, ref) => (
+ <div
+ ref={ref}
+ className={cn("text-sm [&_p]:leading-relaxed", className)}
+ {...props}
+ />
+));
+AlertDescription.displayName = "AlertDescription";
+
+export { Alert, AlertTitle, AlertDescription };
diff --git a/apps/web/src/components/ui/avatar.tsx b/apps/web/src/components/ui/avatar.tsx
index a3b59233..23d4427c 100644
--- a/apps/web/src/components/ui/avatar.tsx
+++ b/apps/web/src/components/ui/avatar.tsx
@@ -1,50 +1,50 @@
-"use client";
-
-import * as React from "react";
-import { Avatar as AvatarPrimitive } from "radix-ui";
-
-import { cn } from "@/utils/ui";
-
-const Avatar = React.forwardRef<
- React.ElementRef<typeof AvatarPrimitive.Root>,
- React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
->(({ className, ...props }, ref) => (
- <AvatarPrimitive.Root
- ref={ref}
- className={cn(
- "relative flex size-10 shrink-0 overflow-hidden rounded-full",
- className,
- )}
- {...props}
- />
-));
-Avatar.displayName = AvatarPrimitive.Root.displayName;
-
-const AvatarImage = React.forwardRef<
- React.ElementRef<typeof AvatarPrimitive.Image>,
- React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
->(({ className, ...props }, ref) => (
- <AvatarPrimitive.Image
- ref={ref}
- className={cn("aspect-square size-full", className)}
- {...props}
- />
-));
-AvatarImage.displayName = AvatarPrimitive.Image.displayName;
-
-const AvatarFallback = React.forwardRef<
- React.ElementRef<typeof AvatarPrimitive.Fallback>,
- React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
->(({ className, ...props }, ref) => (
- <AvatarPrimitive.Fallback
- ref={ref}
- className={cn(
- "bg-muted flex size-full items-center justify-center rounded-full",
- className,
- )}
- {...props}
- />
-));
-AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
-
-export { Avatar, AvatarImage, AvatarFallback };
+"use client";
+
+import * as React from "react";
+import { Avatar as AvatarPrimitive } from "radix-ui";
+
+import { cn } from "@/utils/ui";
+
+const Avatar = React.forwardRef<
+ React.ElementRef<typeof AvatarPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
+>(({ className, ...props }, ref) => (
+ <AvatarPrimitive.Root
+ ref={ref}
+ className={cn(
+ "relative flex size-10 shrink-0 overflow-hidden rounded-full",
+ className,
+ )}
+ {...props}
+ />
+));
+Avatar.displayName = AvatarPrimitive.Root.displayName;
+
+const AvatarImage = React.forwardRef<
+ React.ElementRef<typeof AvatarPrimitive.Image>,
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
+>(({ className, ...props }, ref) => (
+ <AvatarPrimitive.Image
+ ref={ref}
+ className={cn("aspect-square size-full", className)}
+ {...props}
+ />
+));
+AvatarImage.displayName = AvatarPrimitive.Image.displayName;
+
+const AvatarFallback = React.forwardRef<
+ React.ElementRef<typeof AvatarPrimitive.Fallback>,
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
+>(({ className, ...props }, ref) => (
+ <AvatarPrimitive.Fallback
+ ref={ref}
+ className={cn(
+ "bg-muted flex size-full items-center justify-center rounded-full",
+ className,
+ )}
+ {...props}
+ />
+));
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
+
+export { Avatar, AvatarImage, AvatarFallback };
diff --git a/apps/web/src/components/ui/breadcrumb.tsx b/apps/web/src/components/ui/breadcrumb.tsx
index 927cca89..0d51e8bf 100644
--- a/apps/web/src/components/ui/breadcrumb.tsx
+++ b/apps/web/src/components/ui/breadcrumb.tsx
@@ -1,106 +1,106 @@
-import { ChevronRight, MoreHorizontal } from "lucide-react";
-import { Slot } from "radix-ui";
-
-import { cn } from "@/utils/ui";
-
-function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
- return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
-}
-
-function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
- return (
- <ol
- data-slot="breadcrumb-list"
- className={cn(
- "text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
- className,
- )}
- {...props}
- />
- );
-}
-
-function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
- return (
- <li
- data-slot="breadcrumb-item"
- className={cn("inline-flex items-center gap-1.5", className)}
- {...props}
- />
- );
-}
-
-function BreadcrumbLink({
- asChild,
- className,
- ...props
-}: React.ComponentProps<"a"> & {
- asChild?: boolean;
-}) {
- const Comp = asChild ? Slot.Root : "a";
-
- return (
- <Comp
- data-slot="breadcrumb-link"
- className={cn("hover:text-foreground transition-colors", className)}
- {...props}
- />
- );
-}
-
-function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
- return (
- <span
- data-slot="breadcrumb-page"
- aria-current="page"
- className={cn("text-foreground font-normal", className)}
- {...props}
- />
- );
-}
-
-function BreadcrumbSeparator({
- children,
- className,
- ...props
-}: React.ComponentProps<"li">) {
- return (
- <li
- data-slot="breadcrumb-separator"
- role="presentation"
- aria-hidden="true"
- className={cn("[&>svg]:size-3.5", className)}
- {...props}
- >
- {children ?? <ChevronRight />}
- </li>
- );
-}
-
-function BreadcrumbEllipsis({
- className,
- ...props
-}: React.ComponentProps<"span">) {
- return (
- <span
- data-slot="breadcrumb-ellipsis"
- role="presentation"
- aria-hidden="true"
- className={cn("flex size-9 items-center justify-center", className)}
- {...props}
- >
- <MoreHorizontal className="size-4" />
- <span className="sr-only">More</span>
- </span>
- );
-}
-
-export {
- Breadcrumb,
- BreadcrumbList,
- BreadcrumbItem,
- BreadcrumbLink,
- BreadcrumbPage,
- BreadcrumbSeparator,
- BreadcrumbEllipsis,
-};
+import { ChevronRight, MoreHorizontal } from "lucide-react";
+import { Slot } from "radix-ui";
+
+import { cn } from "@/utils/ui";
+
+function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
+ return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
+ return (
+ <ol
+ data-slot="breadcrumb-list"
+ className={cn(
+ "text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+ <li
+ data-slot="breadcrumb-item"
+ className={cn("inline-flex items-center gap-1.5", className)}
+ {...props}
+ />
+ );
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean;
+}) {
+ const Comp = asChild ? Slot.Root : "a";
+
+ return (
+ <Comp
+ data-slot="breadcrumb-link"
+ className={cn("hover:text-foreground transition-colors", className)}
+ {...props}
+ />
+ );
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+ <span
+ data-slot="breadcrumb-page"
+ aria-current="page"
+ className={cn("text-foreground font-normal", className)}
+ {...props}
+ />
+ );
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+ <li
+ data-slot="breadcrumb-separator"
+ role="presentation"
+ aria-hidden="true"
+ className={cn("[&>svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? <ChevronRight />}
+ </li>
+ );
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+ <span
+ data-slot="breadcrumb-ellipsis"
+ role="presentation"
+ aria-hidden="true"
+ className={cn("flex size-9 items-center justify-center", className)}
+ {...props}
+ >
+ <MoreHorizontal className="size-4" />
+ <span className="sr-only">More</span>
+ </span>
+ );
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+};
diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx
index 3dff9417..677d9330 100644
--- a/apps/web/src/components/ui/button.tsx
+++ b/apps/web/src/components/ui/button.tsx
@@ -1,63 +1,63 @@
-import * as React from "react";
-import { Slot as SlotPrimitive } from "radix-ui";
-import { cva, type VariantProps } from "class-variance-authority";
-import { cn } from "@/utils/ui";
-
-const buttonVariants = cva(
- "inline-flex items-center cursor-pointer justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
- {
- variants: {
- variant: {
- default: "bg-foreground text-background hover:bg-foreground/90",
- background: "bg-background text-foreground hover:bg-background/90",
- destructive:
- "bg-destructive text-destructive-foreground hover:bg-destructive/80",
- "destructive-foreground":
- "border bg-background hover:bg-destructive/15 text-destructive",
- outline: "border border-border bg-background hover:bg-accent",
- secondary:
- "bg-secondary text-secondary-foreground border border-secondary-border",
- text: "bg-transparent rounded-none opacity-100 hover:opacity-75",
- ghost: "bg-transparent hover:bg-accent",
- link: "text-primary underline-offset-4 hover:underline !p-0 !h-auto",
- },
- size: {
- default: "h-9 px-4 py-2",
- sm: "h-7 p-1 px-2.5 text-sm rounded-sm",
- lg: "h-10 p-5 px-6",
- icon: "size-7 rounded-sm",
- text: "p-0",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "default",
- },
- },
-);
-
-export interface ButtonProps
- extends React.ButtonHTMLAttributes<HTMLButtonElement>,
- VariantProps<typeof buttonVariants> {
- asChild?: boolean;
-}
-
-const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
- ({ className, variant, size, asChild = false, ...props }, ref) => {
- const Comp = asChild ? SlotPrimitive.Slot : "button";
- const effectiveSize = size ?? (variant === "text" ? "text" : "default");
- return (
- <Comp
- className={cn(
- buttonVariants({ variant, size: effectiveSize, className }),
- )}
- ref={ref}
- type="button"
- {...props}
- />
- );
- },
-);
-Button.displayName = "Button";
-
-export { Button, buttonVariants };
+import * as React from "react";
+import { Slot as SlotPrimitive } from "radix-ui";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/utils/ui";
+
+const buttonVariants = cva(
+ "inline-flex items-center cursor-pointer justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "bg-foreground text-background hover:bg-foreground/90",
+ background: "bg-background text-foreground hover:bg-background/90",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/80",
+ "destructive-foreground":
+ "border bg-background hover:bg-destructive/15 text-destructive",
+ outline: "border border-border bg-background hover:bg-accent",
+ secondary:
+ "bg-secondary text-secondary-foreground border border-secondary-border",
+ text: "bg-transparent rounded-none opacity-100 hover:opacity-75",
+ ghost: "bg-transparent hover:bg-accent",
+ link: "text-primary underline-offset-4 hover:underline !p-0 !h-auto",
+ },
+ size: {
+ default: "h-9 px-4 py-2",
+ sm: "h-7 p-1 px-2.5 text-sm rounded-sm",
+ lg: "h-10 p-5 px-6",
+ icon: "size-7 rounded-sm",
+ text: "p-0",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
+ VariantProps<typeof buttonVariants> {
+ asChild?: boolean;
+}
+
+const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? SlotPrimitive.Slot : "button";
+ const effectiveSize = size ?? (variant === "text" ? "text" : "default");
+ return (
+ <Comp
+ className={cn(
+ buttonVariants({ variant, size: effectiveSize, className }),
+ )}
+ ref={ref}
+ type="button"
+ {...props}
+ />
+ );
+ },
+);
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
diff --git a/apps/web/src/components/ui/calendar.tsx b/apps/web/src/components/ui/calendar.tsx
index 3d088b27..a58ad5b7 100644
--- a/apps/web/src/components/ui/calendar.tsx
+++ b/apps/web/src/components/ui/calendar.tsx
@@ -1,72 +1,72 @@
-"use client";
-
-import type * as React from "react";
-import { ChevronLeft, ChevronRight } from "lucide-react";
-import { DayPicker } from "react-day-picker";
-
-import { cn } from "@/utils/ui";
-import { buttonVariants } from "./button";
-
-export type CalendarProps = React.ComponentProps<typeof DayPicker>;
-
-function Calendar({
- className,
- classNames,
- showOutsideDays = true,
- ...props
-}: CalendarProps) {
- return (
- <DayPicker
- showOutsideDays={showOutsideDays}
- className={cn("p-3", className)}
- classNames={{
- months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
- month: "space-y-4",
- caption: "flex justify-center pt-1 relative items-center",
- caption_label: "text-sm font-medium",
- nav: "space-x-1 flex items-center",
- nav_button: cn(
- buttonVariants({ variant: "outline" }),
- "size-7 bg-transparent p-0 opacity-50 hover:opacity-100",
- ),
- nav_button_previous: "absolute left-1",
- nav_button_next: "absolute right-1",
- table: "w-full border-collapse space-y-1",
- head_row: "flex",
- head_cell:
- "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
- row: "flex w-full mt-2",
- cell: cn(
- "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
- props.mode === "range"
- ? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
- : "[&:has([aria-selected])]:rounded-md",
- ),
- day: cn(
- buttonVariants({ variant: "text" }),
- "size-8 p-0 font-normal aria-selected:opacity-100",
- ),
- day_range_start: "day-range-start",
- day_range_end: "day-range-end",
- day_selected:
- "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
- day_today: "bg-accent text-accent-foreground",
- day_outside:
- "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
- day_disabled: "text-muted-foreground opacity-50",
- day_range_middle:
- "aria-selected:bg-accent aria-selected:text-accent-foreground",
- day_hidden: "invisible",
- ...classNames,
- }}
- components={{
- IconLeft: () => <ChevronLeft className="size-4" />,
- IconRight: () => <ChevronRight className="size-4" />,
- }}
- {...props}
- />
- );
-}
-Calendar.displayName = "Calendar";
-
-export { Calendar };
+"use client";
+
+import type * as React from "react";
+import { ChevronLeft, ChevronRight } from "lucide-react";
+import { DayPicker } from "react-day-picker";
+
+import { cn } from "@/utils/ui";
+import { buttonVariants } from "./button";
+
+export type CalendarProps = React.ComponentProps<typeof DayPicker>;
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ ...props
+}: CalendarProps) {
+ return (
+ <DayPicker
+ showOutsideDays={showOutsideDays}
+ className={cn("p-3", className)}
+ classNames={{
+ months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
+ month: "space-y-4",
+ caption: "flex justify-center pt-1 relative items-center",
+ caption_label: "text-sm font-medium",
+ nav: "space-x-1 flex items-center",
+ nav_button: cn(
+ buttonVariants({ variant: "outline" }),
+ "size-7 bg-transparent p-0 opacity-50 hover:opacity-100",
+ ),
+ nav_button_previous: "absolute left-1",
+ nav_button_next: "absolute right-1",
+ table: "w-full border-collapse space-y-1",
+ head_row: "flex",
+ head_cell:
+ "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
+ row: "flex w-full mt-2",
+ cell: cn(
+ "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
+ props.mode === "range"
+ ? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
+ : "[&:has([aria-selected])]:rounded-md",
+ ),
+ day: cn(
+ buttonVariants({ variant: "text" }),
+ "size-8 p-0 font-normal aria-selected:opacity-100",
+ ),
+ day_range_start: "day-range-start",
+ day_range_end: "day-range-end",
+ day_selected:
+ "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
+ day_today: "bg-accent text-accent-foreground",
+ day_outside:
+ "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
+ day_disabled: "text-muted-foreground opacity-50",
+ day_range_middle:
+ "aria-selected:bg-accent aria-selected:text-accent-foreground",
+ day_hidden: "invisible",
+ ...classNames,
+ }}
+ components={{
+ IconLeft: () => <ChevronLeft className="size-4" />,
+ IconRight: () => <ChevronRight className="size-4" />,
+ }}
+ {...props}
+ />
+ );
+}
+Calendar.displayName = "Calendar";
+
+export { Calendar };
diff --git a/apps/web/src/components/ui/checkbox.tsx b/apps/web/src/components/ui/checkbox.tsx
index afee272e..03c15f26 100644
--- a/apps/web/src/components/ui/checkbox.tsx
+++ b/apps/web/src/components/ui/checkbox.tsx
@@ -1,30 +1,30 @@
-"use client";
-
-import * as React from "react";
-import { Checkbox as CheckboxPrimitive } from "radix-ui";
-import { Check } from "lucide-react";
-
-import { cn } from "@/utils/ui";
-
-const Checkbox = React.forwardRef<
- React.ElementRef<typeof CheckboxPrimitive.Root>,
- React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
->(({ className, ...props }, ref) => (
- <CheckboxPrimitive.Root
- ref={ref}
- className={cn(
- "cursor-default bg-background peer focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary size-4 shrink-0 shadow-xs rounded-sm border focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
- className,
- )}
- {...props}
- >
- <CheckboxPrimitive.Indicator
- className={cn("flex items-center justify-center text-current")}
- >
- <Check className="size-4" />
- </CheckboxPrimitive.Indicator>
- </CheckboxPrimitive.Root>
-));
-Checkbox.displayName = CheckboxPrimitive.Root.displayName;
-
-export { Checkbox };
+"use client";
+
+import * as React from "react";
+import { Checkbox as CheckboxPrimitive } from "radix-ui";
+import { Check } from "lucide-react";
+
+import { cn } from "@/utils/ui";
+
+const Checkbox = React.forwardRef<
+ React.ElementRef<typeof CheckboxPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
+>(({ className, ...props }, ref) => (
+ <CheckboxPrimitive.Root
+ ref={ref}
+ className={cn(
+ "cursor-default bg-background peer focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary size-4 shrink-0 shadow-xs rounded-sm border focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ {...props}
+ >
+ <CheckboxPrimitive.Indicator
+ className={cn("flex items-center justify-center text-current")}
+ >
+ <Check className="size-4" />
+ </CheckboxPrimitive.Indicator>
+ </CheckboxPrimitive.Root>
+));
+Checkbox.displayName = CheckboxPrimitive.Root.displayName;
+
+export { Checkbox };
diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx
index 6d911a75..2bc7ec56 100644
--- a/apps/web/src/components/ui/color-picker.tsx
+++ b/apps/web/src/components/ui/color-picker.tsx
@@ -1,469 +1,469 @@
-import { forwardRef, useEffect, useRef, useState } from "react";
-import { cn } from "@/utils/ui";
-import { Input } from "./input";
-import {
- Popover,
- PopoverClose,
- PopoverContent,
- PopoverTrigger,
-} from "./popover";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "./select";
-import { Button } from "./button";
-import { Cancel01Icon, ColorPickerIcon } from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- type ColorFormat,
- appendAlpha,
- extractColorFromText,
- formatColorValue,
- hexToHsv,
- hsvToHex,
- parseColorInput,
- parseHexAlpha,
-} from "@/utils/color";
-
-interface ColorPickerProps {
- value?: string;
- onChange?: (value: string) => void;
- onChangeEnd?: (value: string) => void;
- className?: string;
-}
-
-const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
- ({ className, value = "FFFFFF", onChange, onChangeEnd, ...props }, ref) => {
- const [isDragging, setIsDragging] = useState<
- "saturation" | "hue" | "opacity" | null
- >(null);
- const [internalHue, setInternalHue] = useState(0);
- const [inputValue, setInputValue] = useState(value);
- const [colorFormat, setColorFormat] = useState<ColorFormat>("hex");
-
- const saturationRef = useRef<HTMLButtonElement>(null);
- const hueRef = useRef<HTMLButtonElement>(null);
- const opacityRef = useRef<HTMLButtonElement>(null);
- const latestDragColorRef = useRef<string | null>(null);
-
- const isEyeDropperSupported =
- typeof window !== "undefined" && "EyeDropper" in window;
-
- const { rgb: rgbValue, alpha } = parseHexAlpha({ hex: value });
- const [h, s, v] = hexToHsv({ hex: rgbValue });
-
- const handleEyeDropper = async () => {
- if (!isEyeDropperSupported || !EyeDropper) return;
- try {
- const dropper = new EyeDropper();
- const result = await dropper.open();
- const hex = result.sRGBHex.replace("#", "").toLowerCase();
- const finalHex = appendAlpha({ rgbHex: hex, alpha });
- onChange?.(finalHex);
- onChangeEnd?.(finalHex);
- } catch {
- // user cancelled the picker
- }
- };
- const hueDiff = Math.abs(h - internalHue);
- const isSameHueWrapped = hueDiff < 1 || Math.abs(hueDiff - 360) < 1;
- const displayHue = s === 0 || isSameHueWrapped ? internalHue : h;
-
- useEffect(() => {
- setInputValue(formatColorValue({ hex: value, format: colorFormat }));
- }, [value, colorFormat]);
-
- useEffect(() => {
- const handleMouseMove = (e: MouseEvent) => {
- if (!isDragging) return;
-
- if (isDragging === "saturation" && saturationRef.current) {
- const rect = saturationRef.current.getBoundingClientRect();
- const x = Math.max(
- 0,
- Math.min(1, (e.clientX - rect.left) / rect.width),
- );
- const y = Math.max(
- 0,
- Math.min(1, (e.clientY - rect.top) / rect.height),
- );
- const newHex = appendAlpha({
- rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }),
- alpha,
- });
- latestDragColorRef.current = newHex;
- onChange?.(newHex);
- }
-
- if (isDragging === "hue" && hueRef.current) {
- const rect = hueRef.current.getBoundingClientRect();
- const x = Math.max(
- 0,
- Math.min(1, (e.clientX - rect.left) / rect.width),
- );
- const newH = x * 360;
- setInternalHue(newH);
- if (s > 0) {
- const newHex = appendAlpha({
- rgbHex: hsvToHex({ h: newH, s, v }),
- alpha,
- });
- latestDragColorRef.current = newHex;
- onChange?.(newHex);
- }
- }
-
- if (isDragging === "opacity" && opacityRef.current) {
- const rect = opacityRef.current.getBoundingClientRect();
- const x = Math.max(
- 0,
- Math.min(1, (e.clientX - rect.left) / rect.width),
- );
- const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x });
- latestDragColorRef.current = newHex;
- onChange?.(newHex);
- }
- };
-
- const handleMouseUp = () => {
- if (latestDragColorRef.current !== null && onChangeEnd) {
- onChangeEnd(latestDragColorRef.current);
- latestDragColorRef.current = null;
- }
- setIsDragging(null);
- };
-
- if (isDragging) {
- document.addEventListener("mousemove", handleMouseMove);
- document.addEventListener("mouseup", handleMouseUp);
- return () => {
- document.removeEventListener("mousemove", handleMouseMove);
- document.removeEventListener("mouseup", handleMouseUp);
- };
- }
- }, [isDragging, displayHue, s, v, alpha, rgbValue, onChange, onChangeEnd]);
-
- const handleSaturationMouseDown = (e: React.MouseEvent) => {
- e.preventDefault();
- const saturationElement = saturationRef.current;
- if (!saturationElement) return;
- setIsDragging("saturation");
- const rect = saturationElement.getBoundingClientRect();
- const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
- const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
- const newHex = appendAlpha({
- rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }),
- alpha,
- });
- latestDragColorRef.current = newHex;
- onChange?.(newHex);
- };
-
- const handleHueMouseDown = (e: React.MouseEvent) => {
- e.preventDefault();
- const hueElement = hueRef.current;
- if (!hueElement) return;
- setIsDragging("hue");
- const rect = hueElement.getBoundingClientRect();
- const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
- const newH = x * 360;
- setInternalHue(newH);
- if (s > 0) {
- const newHex = appendAlpha({
- rgbHex: hsvToHex({ h: newH, s, v }),
- alpha,
- });
- latestDragColorRef.current = newHex;
- onChange?.(newHex);
- }
- };
-
- const handleOpacityMouseDown = (e: React.MouseEvent) => {
- e.preventDefault();
- const opacityElement = opacityRef.current;
- if (!opacityElement) return;
- setIsDragging("opacity");
- const rect = opacityElement.getBoundingClientRect();
- const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
- const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x });
- latestDragColorRef.current = newHex;
- onChange?.(newHex);
- };
-
- const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
- setInputValue(
- colorFormat === "hex"
- ? e.target.value.replace("#", "")
- : e.target.value,
- );
- };
-
- const commitInputValue = () => {
- const parsed = parseColorInput({
- input: inputValue,
- format: colorFormat,
- });
- if (parsed) {
- const nextHex = appendAlpha({ rgbHex: parsed, alpha });
- onChange?.(nextHex);
- onChangeEnd?.(nextHex);
- return;
- }
-
- const extracted = extractColorFromText({ text: inputValue });
- if (extracted) {
- const hasExplicitAlpha = extracted.length > 6;
- const finalHex = hasExplicitAlpha
- ? extracted
- : appendAlpha({ rgbHex: extracted, alpha });
- onChange?.(finalHex);
- onChangeEnd?.(finalHex);
- }
- };
-
- const handleInputBlur = () => {
- commitInputValue();
- };
-
- const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
- if (e.key === "Enter") {
- commitInputValue();
- e.currentTarget.blur();
- }
- };
-
- const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
- const pastedText = event.clipboardData.getData("text");
- const extractedHex = extractColorFromText({ text: pastedText });
- if (!extractedHex) return;
-
- event.preventDefault();
- const hasExplicitAlpha = extractedHex.length > 6;
- const finalHex = hasExplicitAlpha
- ? extractedHex
- : appendAlpha({ rgbHex: extractedHex, alpha });
- onChange?.(finalHex);
- onChangeEnd?.(finalHex);
- };
-
- const saturationStyle = {
- background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${displayHue}, 100%, 50%))`,
- };
-
- const hueStyle = {
- background:
- "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)",
- };
-
- const checkerboardStyle = {
- backgroundImage: `
- linear-gradient(45deg, rgba(0,0,0,0.1) 25%, transparent 25%),
- linear-gradient(-45deg, rgba(0,0,0,0.1) 25%, transparent 25%),
- linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.1) 75%),
- linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.1) 75%)
- `,
- backgroundSize: "8px 8px",
- backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px",
- backgroundColor: "#fff",
- };
-
-
- return (
- <Popover>
- <div
- ref={ref}
- className={cn(
- "bg-accent flex h-8 flex-1 items-center gap-2 rounded-md px-[0.45rem]",
- className,
- )}
- {...props}
- >
- <PopoverTrigger asChild>
- <button
- className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20 overflow-hidden relative"
- type="button"
- >
- <span
- className="absolute inset-0 dark:invert"
- style={checkerboardStyle}
- />
- <span
- className="absolute inset-0"
- style={{ backgroundColor: `#${value}` }}
- />
- </button>
- </PopoverTrigger>
- <div className="flex flex-1 items-center">
- <Input
- className={cn(
- "border-0! bg-transparent p-0 ring-0! ring-offset-0!",
- colorFormat === "hex" && "uppercase",
- )}
- size="sm"
- containerClassName="w-full"
- value={inputValue}
- onChange={handleInputChange}
- onBlur={handleInputBlur}
- onKeyDown={handleInputKeyDown}
- onPaste={handlePaste}
- />
- </div>
- </div>
- <PopoverContent
- className="w-64 px-0 select-none flex flex-col gap-3 py-2"
- side="left"
- sideOffset={8}
- onOpenAutoFocus={(event) => {
- event.preventDefault();
- }}
- onCloseAutoFocus={(event) => {
- event.preventDefault();
- }}
- onInteractOutside={(event) => {
- if (isDragging) event.preventDefault();
- }}
- >
- <header className="border-b flex justify-between items-center pb-2 px-2">
- <Select defaultValue="custom">
- <SelectTrigger variant="outline">
- <SelectValue placeholder="Select a mode" />
- </SelectTrigger>
- <SelectContent position="popper">
- <SelectItem value="custom">Custom</SelectItem>
- <SelectItem value="saved">Saved</SelectItem>
- </SelectContent>
- </Select>
- <div>
- {isEyeDropperSupported && (
- <Button
- variant="ghost"
- size="icon"
- type="button"
- onClick={handleEyeDropper}
- >
- <HugeiconsIcon icon={ColorPickerIcon} />
- </Button>
- )}
- <PopoverClose asChild>
- <Button variant="ghost" size="icon" type="button">
- <HugeiconsIcon icon={Cancel01Icon} />
- </Button>
- </PopoverClose>
- </div>
- </header>
- <div className="px-2 flex flex-col gap-3">
- <button
- ref={saturationRef}
- className="relative h-44 aspect-square w-full appearance-none border-0 bg-transparent p-0"
- style={saturationStyle}
- type="button"
- onMouseDown={handleSaturationMouseDown}
- >
- <ColorCircle
- size="sm"
- position={{ left: `${s * 100}%`, top: `${(1 - v) * 100}%` }}
- color={`#${value}`}
- />
- </button>
-
- <button
- ref={hueRef}
- className="relative h-4 w-full rounded-lg appearance-none border-0 bg-transparent p-0"
- style={hueStyle}
- type="button"
- onMouseDown={handleHueMouseDown}
- >
- <ColorCircle
- size="md"
- position={{
- left: `calc(0.5rem + (100% - 1rem) * ${displayHue / 360})`,
- top: "50%",
- }}
- />
- </button>
-
- <button
- ref={opacityRef}
- className="relative h-4 w-full overflow-hidden rounded-lg appearance-none border-0 p-0"
- type="button"
- onMouseDown={handleOpacityMouseDown}
- >
- <div className="absolute inset-0 dark:invert" style={checkerboardStyle} />
- <div
- className="absolute inset-0 rounded-lg"
- style={{
- background: `linear-gradient(to right, transparent, #${rgbValue})`,
- }}
- />
- <ColorCircle
- size="md"
- position={{
- left: `calc(0.5rem + (100% - 1rem) * ${alpha})`,
- top: "50%",
- }}
- />
- </button>
-
- <div className="flex items-center gap-2">
- <Select
- value={colorFormat}
- onValueChange={(value) => setColorFormat(value as ColorFormat)}
- >
- <SelectTrigger variant="outline" className="min-w-18 max-w-18">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="hex">HEX</SelectItem>
- <SelectItem value="rgb">RGB</SelectItem>
- <SelectItem value="hsl">HSL</SelectItem>
- <SelectItem value="hsv">HSV</SelectItem>
- </SelectContent>
- </Select>
-
- <Input
- className={cn(
- "h-7 rounded-sm p-2.5",
- colorFormat === "hex" && "uppercase",
- )}
- type="text"
- value={inputValue}
- onChange={handleInputChange}
- onBlur={handleInputBlur}
- onKeyDown={handleInputKeyDown}
- onPaste={handlePaste}
- />
- </div>
- </div>
- </PopoverContent>
- </Popover>
- );
- },
-);
-ColorPicker.displayName = "ColorPicker";
-
-const ColorCircle = ({
- size,
- position,
- color,
-}: {
- size: "sm" | "md";
- position: { left: string; top: string };
- color?: string;
-}) => (
- <div
- className={`pointer-events-none absolute rounded-full border-3 border-white shadow-lg ${
- size === "sm" ? "size-3" : "size-4"
- }`}
- style={{
- left: position.left,
- top: position.top,
- transform: "translate(-50%, -50%)",
- backgroundColor: color,
- }}
- />
-);
-
-export { ColorPicker };
+import { forwardRef, useEffect, useRef, useState } from "react";
+import { cn } from "@/utils/ui";
+import { Input } from "./input";
+import {
+ Popover,
+ PopoverClose,
+ PopoverContent,
+ PopoverTrigger,
+} from "./popover";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "./select";
+import { Button } from "./button";
+import { Cancel01Icon, ColorPickerIcon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ type ColorFormat,
+ appendAlpha,
+ extractColorFromText,
+ formatColorValue,
+ hexToHsv,
+ hsvToHex,
+ parseColorInput,
+ parseHexAlpha,
+} from "@/utils/color";
+
+interface ColorPickerProps {
+ value?: string;
+ onChange?: (value: string) => void;
+ onChangeEnd?: (value: string) => void;
+ className?: string;
+}
+
+const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
+ ({ className, value = "FFFFFF", onChange, onChangeEnd, ...props }, ref) => {
+ const [isDragging, setIsDragging] = useState<
+ "saturation" | "hue" | "opacity" | null
+ >(null);
+ const [internalHue, setInternalHue] = useState(0);
+ const [inputValue, setInputValue] = useState(value);
+ const [colorFormat, setColorFormat] = useState<ColorFormat>("hex");
+
+ const saturationRef = useRef<HTMLButtonElement>(null);
+ const hueRef = useRef<HTMLButtonElement>(null);
+ const opacityRef = useRef<HTMLButtonElement>(null);
+ const latestDragColorRef = useRef<string | null>(null);
+
+ const isEyeDropperSupported =
+ typeof window !== "undefined" && "EyeDropper" in window;
+
+ const { rgb: rgbValue, alpha } = parseHexAlpha({ hex: value });
+ const [h, s, v] = hexToHsv({ hex: rgbValue });
+
+ const handleEyeDropper = async () => {
+ if (!isEyeDropperSupported || !EyeDropper) return;
+ try {
+ const dropper = new EyeDropper();
+ const result = await dropper.open();
+ const hex = result.sRGBHex.replace("#", "").toLowerCase();
+ const finalHex = appendAlpha({ rgbHex: hex, alpha });
+ onChange?.(finalHex);
+ onChangeEnd?.(finalHex);
+ } catch {
+ // user cancelled the picker
+ }
+ };
+ const hueDiff = Math.abs(h - internalHue);
+ const isSameHueWrapped = hueDiff < 1 || Math.abs(hueDiff - 360) < 1;
+ const displayHue = s === 0 || isSameHueWrapped ? internalHue : h;
+
+ useEffect(() => {
+ setInputValue(formatColorValue({ hex: value, format: colorFormat }));
+ }, [value, colorFormat]);
+
+ useEffect(() => {
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!isDragging) return;
+
+ if (isDragging === "saturation" && saturationRef.current) {
+ const rect = saturationRef.current.getBoundingClientRect();
+ const x = Math.max(
+ 0,
+ Math.min(1, (e.clientX - rect.left) / rect.width),
+ );
+ const y = Math.max(
+ 0,
+ Math.min(1, (e.clientY - rect.top) / rect.height),
+ );
+ const newHex = appendAlpha({
+ rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }),
+ alpha,
+ });
+ latestDragColorRef.current = newHex;
+ onChange?.(newHex);
+ }
+
+ if (isDragging === "hue" && hueRef.current) {
+ const rect = hueRef.current.getBoundingClientRect();
+ const x = Math.max(
+ 0,
+ Math.min(1, (e.clientX - rect.left) / rect.width),
+ );
+ const newH = x * 360;
+ setInternalHue(newH);
+ if (s > 0) {
+ const newHex = appendAlpha({
+ rgbHex: hsvToHex({ h: newH, s, v }),
+ alpha,
+ });
+ latestDragColorRef.current = newHex;
+ onChange?.(newHex);
+ }
+ }
+
+ if (isDragging === "opacity" && opacityRef.current) {
+ const rect = opacityRef.current.getBoundingClientRect();
+ const x = Math.max(
+ 0,
+ Math.min(1, (e.clientX - rect.left) / rect.width),
+ );
+ const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x });
+ latestDragColorRef.current = newHex;
+ onChange?.(newHex);
+ }
+ };
+
+ const handleMouseUp = () => {
+ if (latestDragColorRef.current !== null && onChangeEnd) {
+ onChangeEnd(latestDragColorRef.current);
+ latestDragColorRef.current = null;
+ }
+ setIsDragging(null);
+ };
+
+ if (isDragging) {
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+ }
+ }, [isDragging, displayHue, s, v, alpha, rgbValue, onChange, onChangeEnd]);
+
+ const handleSaturationMouseDown = (e: React.MouseEvent) => {
+ e.preventDefault();
+ const saturationElement = saturationRef.current;
+ if (!saturationElement) return;
+ setIsDragging("saturation");
+ const rect = saturationElement.getBoundingClientRect();
+ const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
+ const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
+ const newHex = appendAlpha({
+ rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }),
+ alpha,
+ });
+ latestDragColorRef.current = newHex;
+ onChange?.(newHex);
+ };
+
+ const handleHueMouseDown = (e: React.MouseEvent) => {
+ e.preventDefault();
+ const hueElement = hueRef.current;
+ if (!hueElement) return;
+ setIsDragging("hue");
+ const rect = hueElement.getBoundingClientRect();
+ const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
+ const newH = x * 360;
+ setInternalHue(newH);
+ if (s > 0) {
+ const newHex = appendAlpha({
+ rgbHex: hsvToHex({ h: newH, s, v }),
+ alpha,
+ });
+ latestDragColorRef.current = newHex;
+ onChange?.(newHex);
+ }
+ };
+
+ const handleOpacityMouseDown = (e: React.MouseEvent) => {
+ e.preventDefault();
+ const opacityElement = opacityRef.current;
+ if (!opacityElement) return;
+ setIsDragging("opacity");
+ const rect = opacityElement.getBoundingClientRect();
+ const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
+ const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x });
+ latestDragColorRef.current = newHex;
+ onChange?.(newHex);
+ };
+
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ setInputValue(
+ colorFormat === "hex"
+ ? e.target.value.replace("#", "")
+ : e.target.value,
+ );
+ };
+
+ const commitInputValue = () => {
+ const parsed = parseColorInput({
+ input: inputValue,
+ format: colorFormat,
+ });
+ if (parsed) {
+ const nextHex = appendAlpha({ rgbHex: parsed, alpha });
+ onChange?.(nextHex);
+ onChangeEnd?.(nextHex);
+ return;
+ }
+
+ const extracted = extractColorFromText({ text: inputValue });
+ if (extracted) {
+ const hasExplicitAlpha = extracted.length > 6;
+ const finalHex = hasExplicitAlpha
+ ? extracted
+ : appendAlpha({ rgbHex: extracted, alpha });
+ onChange?.(finalHex);
+ onChangeEnd?.(finalHex);
+ }
+ };
+
+ const handleInputBlur = () => {
+ commitInputValue();
+ };
+
+ const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
+ if (e.key === "Enter") {
+ commitInputValue();
+ e.currentTarget.blur();
+ }
+ };
+
+ const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
+ const pastedText = event.clipboardData.getData("text");
+ const extractedHex = extractColorFromText({ text: pastedText });
+ if (!extractedHex) return;
+
+ event.preventDefault();
+ const hasExplicitAlpha = extractedHex.length > 6;
+ const finalHex = hasExplicitAlpha
+ ? extractedHex
+ : appendAlpha({ rgbHex: extractedHex, alpha });
+ onChange?.(finalHex);
+ onChangeEnd?.(finalHex);
+ };
+
+ const saturationStyle = {
+ background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${displayHue}, 100%, 50%))`,
+ };
+
+ const hueStyle = {
+ background:
+ "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)",
+ };
+
+ const checkerboardStyle = {
+ backgroundImage: `
+ linear-gradient(45deg, rgba(0,0,0,0.1) 25%, transparent 25%),
+ linear-gradient(-45deg, rgba(0,0,0,0.1) 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.1) 75%),
+ linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.1) 75%)
+ `,
+ backgroundSize: "8px 8px",
+ backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px",
+ backgroundColor: "#fff",
+ };
+
+
+ return (
+ <Popover>
+ <div
+ ref={ref}
+ className={cn(
+ "bg-accent flex h-8 flex-1 items-center gap-2 rounded-md px-[0.45rem]",
+ className,
+ )}
+ {...props}
+ >
+ <PopoverTrigger asChild>
+ <button
+ className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20 overflow-hidden relative"
+ type="button"
+ >
+ <span
+ className="absolute inset-0 dark:invert"
+ style={checkerboardStyle}
+ />
+ <span
+ className="absolute inset-0"
+ style={{ backgroundColor: `#${value}` }}
+ />
+ </button>
+ </PopoverTrigger>
+ <div className="flex flex-1 items-center">
+ <Input
+ className={cn(
+ "border-0! bg-transparent p-0 ring-0! ring-offset-0!",
+ colorFormat === "hex" && "uppercase",
+ )}
+ size="sm"
+ containerClassName="w-full"
+ value={inputValue}
+ onChange={handleInputChange}
+ onBlur={handleInputBlur}
+ onKeyDown={handleInputKeyDown}
+ onPaste={handlePaste}
+ />
+ </div>
+ </div>
+ <PopoverContent
+ className="w-64 px-0 select-none flex flex-col gap-3 py-2"
+ side="left"
+ sideOffset={8}
+ onOpenAutoFocus={(event) => {
+ event.preventDefault();
+ }}
+ onCloseAutoFocus={(event) => {
+ event.preventDefault();
+ }}
+ onInteractOutside={(event) => {
+ if (isDragging) event.preventDefault();
+ }}
+ >
+ <header className="border-b flex justify-between items-center pb-2 px-2">
+ <Select defaultValue="custom">
+ <SelectTrigger variant="outline">
+ <SelectValue placeholder="Select a mode" />
+ </SelectTrigger>
+ <SelectContent position="popper">
+ <SelectItem value="custom">Custom</SelectItem>
+ <SelectItem value="saved">Saved</SelectItem>
+ </SelectContent>
+ </Select>
+ <div>
+ {isEyeDropperSupported && (
+ <Button
+ variant="ghost"
+ size="icon"
+ type="button"
+ onClick={handleEyeDropper}
+ >
+ <HugeiconsIcon icon={ColorPickerIcon} />
+ </Button>
+ )}
+ <PopoverClose asChild>
+ <Button variant="ghost" size="icon" type="button">
+ <HugeiconsIcon icon={Cancel01Icon} />
+ </Button>
+ </PopoverClose>
+ </div>
+ </header>
+ <div className="px-2 flex flex-col gap-3">
+ <button
+ ref={saturationRef}
+ className="relative h-44 aspect-square w-full appearance-none border-0 bg-transparent p-0"
+ style={saturationStyle}
+ type="button"
+ onMouseDown={handleSaturationMouseDown}
+ >
+ <ColorCircle
+ size="sm"
+ position={{ left: `${s * 100}%`, top: `${(1 - v) * 100}%` }}
+ color={`#${value}`}
+ />
+ </button>
+
+ <button
+ ref={hueRef}
+ className="relative h-4 w-full rounded-lg appearance-none border-0 bg-transparent p-0"
+ style={hueStyle}
+ type="button"
+ onMouseDown={handleHueMouseDown}
+ >
+ <ColorCircle
+ size="md"
+ position={{
+ left: `calc(0.5rem + (100% - 1rem) * ${displayHue / 360})`,
+ top: "50%",
+ }}
+ />
+ </button>
+
+ <button
+ ref={opacityRef}
+ className="relative h-4 w-full overflow-hidden rounded-lg appearance-none border-0 p-0"
+ type="button"
+ onMouseDown={handleOpacityMouseDown}
+ >
+ <div className="absolute inset-0 dark:invert" style={checkerboardStyle} />
+ <div
+ className="absolute inset-0 rounded-lg"
+ style={{
+ background: `linear-gradient(to right, transparent, #${rgbValue})`,
+ }}
+ />
+ <ColorCircle
+ size="md"
+ position={{
+ left: `calc(0.5rem + (100% - 1rem) * ${alpha})`,
+ top: "50%",
+ }}
+ />
+ </button>
+
+ <div className="flex items-center gap-2">
+ <Select
+ value={colorFormat}
+ onValueChange={(value) => setColorFormat(value as ColorFormat)}
+ >
+ <SelectTrigger variant="outline" className="min-w-18 max-w-18">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="hex">HEX</SelectItem>
+ <SelectItem value="rgb">RGB</SelectItem>
+ <SelectItem value="hsl">HSL</SelectItem>
+ <SelectItem value="hsv">HSV</SelectItem>
+ </SelectContent>
+ </Select>
+
+ <Input
+ className={cn(
+ "h-7 rounded-sm p-2.5",
+ colorFormat === "hex" && "uppercase",
+ )}
+ type="text"
+ value={inputValue}
+ onChange={handleInputChange}
+ onBlur={handleInputBlur}
+ onKeyDown={handleInputKeyDown}
+ onPaste={handlePaste}
+ />
+ </div>
+ </div>
+ </PopoverContent>
+ </Popover>
+ );
+ },
+);
+ColorPicker.displayName = "ColorPicker";
+
+const ColorCircle = ({
+ size,
+ position,
+ color,
+}: {
+ size: "sm" | "md";
+ position: { left: string; top: string };
+ color?: string;
+}) => (
+ <div
+ className={`pointer-events-none absolute rounded-full border-3 border-white shadow-lg ${
+ size === "sm" ? "size-3" : "size-4"
+ }`}
+ style={{
+ left: position.left,
+ top: position.top,
+ transform: "translate(-50%, -50%)",
+ backgroundColor: color,
+ }}
+ />
+);
+
+export { ColorPicker };
diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx
index ec02de6b..2279de5a 100644
--- a/apps/web/src/components/ui/context-menu.tsx
+++ b/apps/web/src/components/ui/context-menu.tsx
@@ -1,303 +1,303 @@
-"use client";
-
-import * as React from "react";
-import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
-import { cva, type VariantProps } from "class-variance-authority";
-import { cn } from "@/utils/ui";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- Tick02Icon,
- ArrowRightIcon,
- CircleIcon,
-} from "@hugeicons/core-free-icons";
-import { useOverlayOpenChange } from "./use-overlay-open-change";
-
-function ContextMenu({
- onOpenChange,
- ...props
-}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
- const handleOpenChange = useOverlayOpenChange({
- source: "context-menu",
- onOpenChange,
- });
- return (
- <ContextMenuPrimitive.Root onOpenChange={handleOpenChange} {...props} />
- );
-}
-
-const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
-
-const ContextMenuGroup = ContextMenuPrimitive.Group;
-
-const ContextMenuPortal = ContextMenuPrimitive.Portal;
-
-const ContextMenuSub = ContextMenuPrimitive.Sub;
-
-const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
-
-const contextMenuItemVariants = cva(
- "relative flex cursor-pointer select-none items-center gap-2.5 px-4 py-1.5 text-sm outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
- {
- variants: {
- variant: {
- default:
- "focus:bg-accent focus:text-accent-foreground [&_svg]:text-muted-foreground",
- destructive:
- "text-destructive focus:bg-destructive/10 focus:text-destructive [&_svg]:text-destructive",
- },
- },
- defaultVariants: {
- variant: "default",
- },
- },
-);
-
-const ContextMenuSubTrigger = React.forwardRef<
- React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
- React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
- inset?: boolean;
- variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
- icon?: React.ReactNode;
- }
->(
- (
- { className, inset, children, variant = "default", icon, ...props },
- ref,
- ) => (
- <ContextMenuPrimitive.SubTrigger
- ref={ref}
- className={cn(
- contextMenuItemVariants({ variant }),
- "data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
- inset && "pl-8",
- className,
- )}
- {...props}
- >
- {icon && (
- <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>
- )}
- {children}
- <HugeiconsIcon
- icon={ArrowRightIcon}
- className="ml-auto text-muted-foreground/80"
- />
- </ContextMenuPrimitive.SubTrigger>
- ),
-);
-ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
-
-const ContextMenuSubContent = React.forwardRef<
- React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
- React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
->(({ className, ...props }, ref) => (
- <ContextMenuPrimitive.SubContent
- ref={ref}
- className={cn(
- "bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-2.5",
- className,
- )}
- {...props}
- />
-));
-ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
-
-const ContextMenuContent = React.forwardRef<
- React.ElementRef<typeof ContextMenuPrimitive.Content>,
- React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & {
- container?: HTMLElement | null;
- }
->(({ className, container, ...props }, ref) => (
- <ContextMenuPrimitive.Portal container={container ?? undefined}>
- <ContextMenuPrimitive.Content
- ref={ref}
- className={cn(
- "bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-1.5",
- className,
- )}
- {...props}
- />
- </ContextMenuPrimitive.Portal>
-));
-ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
-
-const ContextMenuItem = React.forwardRef<
- React.ElementRef<typeof ContextMenuPrimitive.Item>,
- React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
- inset?: boolean;
- variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
- icon?: React.ReactNode;
- textRight?: string;
- }
->(
- (
- {
- className,
- inset,
- variant = "default",
- icon,
- children,
- textRight,
- ...props
- },
- ref,
- ) => {
- const shouldInsetContent = inset || Boolean(icon);
-
- return (
- <ContextMenuPrimitive.Item
- ref={ref}
- className={cn(
- contextMenuItemVariants({ variant }),
- shouldInsetContent && "pl-8",
- className,
- )}
- {...props}
- >
- {icon && (
- <span className="absolute left-3 flex size-3.5 items-center justify-center text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0">
- {icon}
- </span>
- )}
- {children}
- {textRight && (
- <span className="ml-auto text-[0.60rem] tracking-widest text-muted-foreground/80 mb-0.5">
- {textRight}
- </span>
- )}
- </ContextMenuPrimitive.Item>
- );
- },
-);
-ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
-
-const ContextMenuCheckboxItem = React.forwardRef<
- React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
- React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & {
- variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
- icon?: React.ReactNode;
- }
->(
- (
- { className, children, checked, variant = "default", icon, ...props },
- ref,
- ) => (
- <ContextMenuPrimitive.CheckboxItem
- ref={ref}
- className={cn(
- contextMenuItemVariants({ variant }),
- "pr-2 pl-8",
- className,
- )}
- checked={checked}
- {...props}
- >
- <span className="absolute left-3 flex size-3.5 items-center justify-center">
- <ContextMenuPrimitive.ItemIndicator>
- <HugeiconsIcon icon={Tick02Icon} className="size-4" />
- </ContextMenuPrimitive.ItemIndicator>
- </span>
- {icon && (
- <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>
- )}
- {children}
- </ContextMenuPrimitive.CheckboxItem>
- ),
-);
-ContextMenuCheckboxItem.displayName =
- ContextMenuPrimitive.CheckboxItem.displayName;
-
-const ContextMenuRadioItem = React.forwardRef<
- React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
- React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & {
- variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
- icon?: React.ReactNode;
- }
->(({ className, children, variant = "default", icon, ...props }, ref) => (
- <ContextMenuPrimitive.RadioItem
- ref={ref}
- className={cn(contextMenuItemVariants({ variant }), "pr-2 pl-8", className)}
- {...props}
- >
- <span className="absolute left-2 flex size-3.5 items-center justify-center">
- <ContextMenuPrimitive.ItemIndicator>
- <HugeiconsIcon icon={CircleIcon} className="size-2 fill-current" />
- </ContextMenuPrimitive.ItemIndicator>
- </span>
- {icon && (
- <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>
- )}
- {children}
- </ContextMenuPrimitive.RadioItem>
-));
-ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
-
-const ContextMenuLabel = React.forwardRef<
- React.ElementRef<typeof ContextMenuPrimitive.Label>,
- React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
- inset?: boolean;
- icon?: React.ReactNode;
- }
->(({ className, inset, icon, children, ...props }, ref) => (
- <ContextMenuPrimitive.Label
- ref={ref}
- className={cn(
- "flex items-center gap-2.5 px-4 py-1.5 text-sm font-semibold text-foreground",
- inset && "pl-8",
- className,
- )}
- {...props}
- >
- {icon && (
- <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>
- )}
- {children}
- </ContextMenuPrimitive.Label>
-));
-ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
-
-const ContextMenuSeparator = React.forwardRef<
- React.ElementRef<typeof ContextMenuPrimitive.Separator>,
- React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
->(({ className, ...props }, ref) => (
- <ContextMenuPrimitive.Separator
- ref={ref}
- className={cn("bg-border my-2 h-px", className)}
- {...props}
- />
-));
-ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
-
-const ContextMenuShortcut = ({
- className,
- ...props
-}: React.HTMLAttributes<HTMLSpanElement>) => {
- return (
- <span
- className={cn(
- "ml-auto text-xs tracking-widest text-muted-foreground opacity-60",
- className,
- )}
- {...props}
- />
- );
-};
-ContextMenuShortcut.displayName = "ContextMenuShortcut";
-
-export {
- ContextMenu,
- ContextMenuTrigger,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuCheckboxItem,
- ContextMenuRadioItem,
- ContextMenuLabel,
- ContextMenuSeparator,
- ContextMenuShortcut,
- ContextMenuGroup,
- ContextMenuPortal,
- ContextMenuSub,
- ContextMenuSubContent,
- ContextMenuSubTrigger,
- ContextMenuRadioGroup,
-};
+"use client";
+
+import * as React from "react";
+import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ Tick02Icon,
+ ArrowRightIcon,
+ CircleIcon,
+} from "@hugeicons/core-free-icons";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
+
+function ContextMenu({
+ onOpenChange,
+ ...props
+}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "context-menu",
+ onOpenChange,
+ });
+ return (
+ <ContextMenuPrimitive.Root onOpenChange={handleOpenChange} {...props} />
+ );
+}
+
+const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
+
+const ContextMenuGroup = ContextMenuPrimitive.Group;
+
+const ContextMenuPortal = ContextMenuPrimitive.Portal;
+
+const ContextMenuSub = ContextMenuPrimitive.Sub;
+
+const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
+
+const contextMenuItemVariants = cva(
+ "relative flex cursor-pointer select-none items-center gap-2.5 px-4 py-1.5 text-sm outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default:
+ "focus:bg-accent focus:text-accent-foreground [&_svg]:text-muted-foreground",
+ destructive:
+ "text-destructive focus:bg-destructive/10 focus:text-destructive [&_svg]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+const ContextMenuSubTrigger = React.forwardRef<
+ React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
+ inset?: boolean;
+ variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
+ icon?: React.ReactNode;
+ }
+>(
+ (
+ { className, inset, children, variant = "default", icon, ...props },
+ ref,
+ ) => (
+ <ContextMenuPrimitive.SubTrigger
+ ref={ref}
+ className={cn(
+ contextMenuItemVariants({ variant }),
+ "data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
+ inset && "pl-8",
+ className,
+ )}
+ {...props}
+ >
+ {icon && (
+ <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>
+ )}
+ {children}
+ <HugeiconsIcon
+ icon={ArrowRightIcon}
+ className="ml-auto text-muted-foreground/80"
+ />
+ </ContextMenuPrimitive.SubTrigger>
+ ),
+);
+ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
+
+const ContextMenuSubContent = React.forwardRef<
+ React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
+>(({ className, ...props }, ref) => (
+ <ContextMenuPrimitive.SubContent
+ ref={ref}
+ className={cn(
+ "bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-2.5",
+ className,
+ )}
+ {...props}
+ />
+));
+ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
+
+const ContextMenuContent = React.forwardRef<
+ React.ElementRef<typeof ContextMenuPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & {
+ container?: HTMLElement | null;
+ }
+>(({ className, container, ...props }, ref) => (
+ <ContextMenuPrimitive.Portal container={container ?? undefined}>
+ <ContextMenuPrimitive.Content
+ ref={ref}
+ className={cn(
+ "bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-1.5",
+ className,
+ )}
+ {...props}
+ />
+ </ContextMenuPrimitive.Portal>
+));
+ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
+
+const ContextMenuItem = React.forwardRef<
+ React.ElementRef<typeof ContextMenuPrimitive.Item>,
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
+ inset?: boolean;
+ variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
+ icon?: React.ReactNode;
+ textRight?: string;
+ }
+>(
+ (
+ {
+ className,
+ inset,
+ variant = "default",
+ icon,
+ children,
+ textRight,
+ ...props
+ },
+ ref,
+ ) => {
+ const shouldInsetContent = inset || Boolean(icon);
+
+ return (
+ <ContextMenuPrimitive.Item
+ ref={ref}
+ className={cn(
+ contextMenuItemVariants({ variant }),
+ shouldInsetContent && "pl-8",
+ className,
+ )}
+ {...props}
+ >
+ {icon && (
+ <span className="absolute left-3 flex size-3.5 items-center justify-center text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0">
+ {icon}
+ </span>
+ )}
+ {children}
+ {textRight && (
+ <span className="ml-auto text-[0.60rem] tracking-widest text-muted-foreground/80 mb-0.5">
+ {textRight}
+ </span>
+ )}
+ </ContextMenuPrimitive.Item>
+ );
+ },
+);
+ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
+
+const ContextMenuCheckboxItem = React.forwardRef<
+ React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & {
+ variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
+ icon?: React.ReactNode;
+ }
+>(
+ (
+ { className, children, checked, variant = "default", icon, ...props },
+ ref,
+ ) => (
+ <ContextMenuPrimitive.CheckboxItem
+ ref={ref}
+ className={cn(
+ contextMenuItemVariants({ variant }),
+ "pr-2 pl-8",
+ className,
+ )}
+ checked={checked}
+ {...props}
+ >
+ <span className="absolute left-3 flex size-3.5 items-center justify-center">
+ <ContextMenuPrimitive.ItemIndicator>
+ <HugeiconsIcon icon={Tick02Icon} className="size-4" />
+ </ContextMenuPrimitive.ItemIndicator>
+ </span>
+ {icon && (
+ <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>
+ )}
+ {children}
+ </ContextMenuPrimitive.CheckboxItem>
+ ),
+);
+ContextMenuCheckboxItem.displayName =
+ ContextMenuPrimitive.CheckboxItem.displayName;
+
+const ContextMenuRadioItem = React.forwardRef<
+ React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & {
+ variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
+ icon?: React.ReactNode;
+ }
+>(({ className, children, variant = "default", icon, ...props }, ref) => (
+ <ContextMenuPrimitive.RadioItem
+ ref={ref}
+ className={cn(contextMenuItemVariants({ variant }), "pr-2 pl-8", className)}
+ {...props}
+ >
+ <span className="absolute left-2 flex size-3.5 items-center justify-center">
+ <ContextMenuPrimitive.ItemIndicator>
+ <HugeiconsIcon icon={CircleIcon} className="size-2 fill-current" />
+ </ContextMenuPrimitive.ItemIndicator>
+ </span>
+ {icon && (
+ <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>
+ )}
+ {children}
+ </ContextMenuPrimitive.RadioItem>
+));
+ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
+
+const ContextMenuLabel = React.forwardRef<
+ React.ElementRef<typeof ContextMenuPrimitive.Label>,
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
+ inset?: boolean;
+ icon?: React.ReactNode;
+ }
+>(({ className, inset, icon, children, ...props }, ref) => (
+ <ContextMenuPrimitive.Label
+ ref={ref}
+ className={cn(
+ "flex items-center gap-2.5 px-4 py-1.5 text-sm font-semibold text-foreground",
+ inset && "pl-8",
+ className,
+ )}
+ {...props}
+ >
+ {icon && (
+ <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>
+ )}
+ {children}
+ </ContextMenuPrimitive.Label>
+));
+ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
+
+const ContextMenuSeparator = React.forwardRef<
+ React.ElementRef<typeof ContextMenuPrimitive.Separator>,
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
+>(({ className, ...props }, ref) => (
+ <ContextMenuPrimitive.Separator
+ ref={ref}
+ className={cn("bg-border my-2 h-px", className)}
+ {...props}
+ />
+));
+ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
+
+const ContextMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLSpanElement>) => {
+ return (
+ <span
+ className={cn(
+ "ml-auto text-xs tracking-widest text-muted-foreground opacity-60",
+ className,
+ )}
+ {...props}
+ />
+ );
+};
+ContextMenuShortcut.displayName = "ContextMenuShortcut";
+
+export {
+ ContextMenu,
+ ContextMenuTrigger,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuCheckboxItem,
+ ContextMenuRadioItem,
+ ContextMenuLabel,
+ ContextMenuSeparator,
+ ContextMenuShortcut,
+ ContextMenuGroup,
+ ContextMenuPortal,
+ ContextMenuSub,
+ ContextMenuSubContent,
+ ContextMenuSubTrigger,
+ ContextMenuRadioGroup,
+};
diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx
index f5045f95..60ea46c1 100644
--- a/apps/web/src/components/ui/dialog.tsx
+++ b/apps/web/src/components/ui/dialog.tsx
@@ -1,145 +1,145 @@
-"use client";
-
-import * as React from "react";
-import { Dialog as DialogPrimitive } from "radix-ui";
-import { X } from "lucide-react";
-import { cn } from "@/utils/ui";
-import { useOverlayOpenChange } from "./use-overlay-open-change";
-
-function Dialog({
- open,
- onOpenChange,
- ...props
-}: React.ComponentProps<typeof DialogPrimitive.Root>) {
- const handleOpenChange = useOverlayOpenChange({
- source: "dialog",
- open,
- onOpenChange,
- });
- return (
- <DialogPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props} />
- );
-}
-
-const DialogTrigger = DialogPrimitive.Trigger;
-
-const DialogPortal = DialogPrimitive.Portal;
-
-const DialogClose = DialogPrimitive.Close;
-
-const DialogOverlay = React.forwardRef<
- React.ElementRef<typeof DialogPrimitive.Overlay>,
- React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
->(({ className, ...props }, ref) => (
- <DialogPrimitive.Overlay
- ref={ref}
- className={cn(
- "fixed inset-0 z-250 backdrop-blur-sm bg-black/10",
- className,
- )}
- {...props}
- />
-));
-DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
-
-const DialogContent = React.forwardRef<
- React.ElementRef<typeof DialogPrimitive.Content>,
- React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
->(({ className, children, ...props }, ref) => (
- <DialogPortal>
- <DialogOverlay />
- <DialogPrimitive.Content
- ref={ref}
- className={cn(
- "bg-popover fixed top-[50%] left-[50%] z-250 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] rounded-lg border shadow-lg duration-200",
- className,
- )}
- onCloseAutoFocus={(e) => {
- e.stopPropagation();
- e.preventDefault();
- }}
- {...props}
- >
- {children}
- <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-6 right-6 cursor-pointer opacity-70 hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
- <X className="size-5 text-muted-foreground" />
- <span className="sr-only">Close</span>
- </DialogPrimitive.Close>
- </DialogPrimitive.Content>
- </DialogPortal>
-));
-DialogContent.displayName = DialogPrimitive.Content.displayName;
-
-const DialogHeader = ({
- className,
- ...props
-}: React.HTMLAttributes<HTMLDivElement>) => (
- <div
- className={cn("flex flex-col space-y-2 text-left border-b p-6", className)}
- {...props}
- />
-);
-DialogHeader.displayName = "DialogHeader";
-
-const DialogBody = ({
- className,
- ...props
-}: React.HTMLAttributes<HTMLDivElement>) => (
- <div className={cn("p-6 flex flex-col gap-4", className)} {...props} />
-);
-DialogBody.displayName = "DialogBody";
-
-const DialogFooter = ({
- className,
- ...props
-}: React.HTMLAttributes<HTMLDivElement>) => (
- <div
- className={cn(
- "flex gap-3 flex-col-reverse sm:flex-row sm:justify-end p-6 py-5 border-t",
- className,
- )}
- {...props}
- />
-);
-DialogFooter.displayName = "DialogFooter";
-
-const DialogTitle = React.forwardRef<
- React.ElementRef<typeof DialogPrimitive.Title>,
- React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
->(({ className, ...props }, ref) => (
- <DialogPrimitive.Title
- ref={ref}
- className={cn(
- "text-lg leading-none font-semibold tracking-tight",
- className,
- )}
- {...props}
- />
-));
-DialogTitle.displayName = DialogPrimitive.Title.displayName;
-
-const DialogDescription = React.forwardRef<
- React.ElementRef<typeof DialogPrimitive.Description>,
- React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
->(({ className, ...props }, ref) => (
- <DialogPrimitive.Description
- ref={ref}
- className={cn("text-muted-foreground text-sm", className)}
- {...props}
- />
-));
-DialogDescription.displayName = DialogPrimitive.Description.displayName;
-
-export {
- Dialog,
- DialogPortal,
- DialogOverlay,
- DialogTrigger,
- DialogClose,
- DialogContent,
- DialogHeader,
- DialogBody,
- DialogFooter,
- DialogTitle,
- DialogDescription,
-};
+"use client";
+
+import * as React from "react";
+import { Dialog as DialogPrimitive } from "radix-ui";
+import { X } from "lucide-react";
+import { cn } from "@/utils/ui";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
+
+function Dialog({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps<typeof DialogPrimitive.Root>) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "dialog",
+ open,
+ onOpenChange,
+ });
+ return (
+ <DialogPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props} />
+ );
+}
+
+const DialogTrigger = DialogPrimitive.Trigger;
+
+const DialogPortal = DialogPrimitive.Portal;
+
+const DialogClose = DialogPrimitive.Close;
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Overlay>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Overlay
+ ref={ref}
+ className={cn(
+ "fixed inset-0 z-250 backdrop-blur-sm bg-black/10",
+ className,
+ )}
+ {...props}
+ />
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
+>(({ className, children, ...props }, ref) => (
+ <DialogPortal>
+ <DialogOverlay />
+ <DialogPrimitive.Content
+ ref={ref}
+ className={cn(
+ "bg-popover fixed top-[50%] left-[50%] z-250 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] rounded-lg border shadow-lg duration-200",
+ className,
+ )}
+ onCloseAutoFocus={(e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ }}
+ {...props}
+ >
+ {children}
+ <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-6 right-6 cursor-pointer opacity-70 hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
+ <X className="size-5 text-muted-foreground" />
+ <span className="sr-only">Close</span>
+ </DialogPrimitive.Close>
+ </DialogPrimitive.Content>
+ </DialogPortal>
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={cn("flex flex-col space-y-2 text-left border-b p-6", className)}
+ {...props}
+ />
+);
+DialogHeader.displayName = "DialogHeader";
+
+const DialogBody = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div className={cn("p-6 flex flex-col gap-4", className)} {...props} />
+);
+DialogBody.displayName = "DialogBody";
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={cn(
+ "flex gap-3 flex-col-reverse sm:flex-row sm:justify-end p-6 py-5 border-t",
+ className,
+ )}
+ {...props}
+ />
+);
+DialogFooter.displayName = "DialogFooter";
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Title>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Title
+ ref={ref}
+ className={cn(
+ "text-lg leading-none font-semibold tracking-tight",
+ className,
+ )}
+ {...props}
+ />
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Description>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Description
+ ref={ref}
+ className={cn("text-muted-foreground text-sm", className)}
+ {...props}
+ />
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogTrigger,
+ DialogClose,
+ DialogContent,
+ DialogHeader,
+ DialogBody,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+};
diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx
index 91913e25..62e69cf1 100644
--- a/apps/web/src/components/ui/dropdown-menu.tsx
+++ b/apps/web/src/components/ui/dropdown-menu.tsx
@@ -1,293 +1,293 @@
-"use client";
-
-import * as React from "react";
-import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
-import { Check, ChevronRight, Circle } from "lucide-react";
-import { cva, type VariantProps } from "class-variance-authority";
-import { cn } from "@/utils/ui";
-import { useOverlayOpenChange } from "./use-overlay-open-change";
-
-function DropdownMenu({
- open,
- onOpenChange,
- ...props
-}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
- const handleOpenChange = useOverlayOpenChange({
- source: "dropdown-menu",
- open,
- onOpenChange,
- });
- return (
- <DropdownMenuPrimitive.Root
- open={open}
- onOpenChange={handleOpenChange}
- {...props}
- />
- );
-}
-
-const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
-
-const DropdownMenuGroup = DropdownMenuPrimitive.Group;
-
-const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
-
-const DropdownMenuSub = DropdownMenuPrimitive.Sub;
-
-const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
-
-const dropdownMenuItemVariants = cva(
- "relative flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-sm text-foreground outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
- {
- variants: {
- variant: {
- default: "",
- destructive:
- "text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive",
- },
- },
- defaultVariants: {
- variant: "default",
- },
- },
-);
-
-const DropdownMenuSubTrigger = React.forwardRef<
- React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
- React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
- inset?: boolean;
- variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
- }
->(({ className, inset, children, variant = "default", ...props }, ref) => (
- <DropdownMenuPrimitive.SubTrigger
- ref={ref}
- className={cn(
- dropdownMenuItemVariants({ variant }),
- "data-[state=open]:bg-muted data-[state=open]:text-foreground",
- inset && "pl-8",
- className,
- )}
- {...props}
- >
- {children}
- <ChevronRight className="ml-auto" />
- </DropdownMenuPrimitive.SubTrigger>
-));
-DropdownMenuSubTrigger.displayName =
- DropdownMenuPrimitive.SubTrigger.displayName;
-
-const DropdownMenuSubContent = React.forwardRef<
- React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
- React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
->(({ className, ...props }, ref) => (
- <DropdownMenuPrimitive.SubContent
- ref={ref}
- className={cn(
- "group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg",
- className,
- )}
- {...props}
- />
-));
-DropdownMenuSubContent.displayName =
- DropdownMenuPrimitive.SubContent.displayName;
-
-const DropdownMenuContent = React.forwardRef<
- React.ElementRef<typeof DropdownMenuPrimitive.Content>,
- React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
->(({ className, sideOffset = 4, ...props }, ref) => (
- <DropdownMenuPrimitive.Portal>
- <DropdownMenuPrimitive.Content
- ref={ref}
- sideOffset={sideOffset}
- onCloseAutoFocus={(e) => {
- e.stopPropagation();
- e.preventDefault();
- }}
- className={cn(
- "group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-lg border p-1.5 shadow-lg",
- className,
- )}
- {...props}
- />
- </DropdownMenuPrimitive.Portal>
-));
-DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
-
-const DropdownMenuItem = React.forwardRef<
- React.ElementRef<typeof DropdownMenuPrimitive.Item>,
- React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
- inset?: boolean;
- icon?: React.ReactNode;
- variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
- }
->(
- (
- {
- className,
- inset,
- icon,
- variant = "default",
- children,
- asChild,
- ...props
- },
- ref,
- ) => {
- const iconSlot = (
- <span className="hidden size-4 shrink-0 items-center justify-center group-has-data-has-icon/menu:flex">
- {icon}
- </span>
- );
-
- const renderedChildren =
- asChild && React.isValidElement(children) ? (
- React.cloneElement(
- children as React.ReactElement<{ children?: React.ReactNode }>,
- {},
- iconSlot,
- (children as React.ReactElement<{ children?: React.ReactNode }>).props
- .children,
- )
- ) : (
- <>
- {iconSlot}
- {children}
- </>
- );
-
- return (
- <DropdownMenuPrimitive.Item
- ref={ref}
- asChild={asChild}
- data-has-icon={icon ? "" : undefined}
- className={cn(
- dropdownMenuItemVariants({ variant }),
- inset && "pl-8",
- className,
- )}
- {...props}
- >
- {renderedChildren}
- </DropdownMenuPrimitive.Item>
- );
- },
-);
-DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
-
-const DropdownMenuCheckboxItem = React.forwardRef<
- React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
- React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
- variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
- }
->(({ className, children, checked, variant = "default", ...props }, ref) => (
- <DropdownMenuPrimitive.CheckboxItem
- ref={ref}
- className={cn(
- dropdownMenuItemVariants({ variant }),
- "pr-8 pl-2",
- className,
- )}
- checked={checked}
- onSelect={(e) => {
- e.preventDefault();
- }}
- {...props}
- >
- {children}
- <span className="absolute right-2 flex size-3.5 items-center justify-center">
- <DropdownMenuPrimitive.ItemIndicator>
- <Check className="size-4" />
- </DropdownMenuPrimitive.ItemIndicator>
- </span>
- </DropdownMenuPrimitive.CheckboxItem>
-));
-
-DropdownMenuCheckboxItem.displayName =
- DropdownMenuPrimitive.CheckboxItem.displayName;
-
-const DropdownMenuRadioItem = React.forwardRef<
- React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
- React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
- variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
- }
->(({ className, children, variant = "default", ...props }, ref) => (
- <DropdownMenuPrimitive.RadioItem
- ref={ref}
- className={cn(
- dropdownMenuItemVariants({ variant }),
- "pr-2 pl-8",
- className,
- )}
- {...props}
- >
- <span className="absolute left-2 flex size-3.5 items-center justify-center">
- <DropdownMenuPrimitive.ItemIndicator>
- <Circle className="size-2 fill-current" />
- </DropdownMenuPrimitive.ItemIndicator>
- </span>
- {children}
- </DropdownMenuPrimitive.RadioItem>
-));
-DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
-
-const DropdownMenuLabel = React.forwardRef<
- React.ElementRef<typeof DropdownMenuPrimitive.Label>,
- React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
- inset?: boolean;
- }
->(({ className, inset, ...props }, ref) => (
- <DropdownMenuPrimitive.Label
- ref={ref}
- className={cn(
- "px-3 pb-2 pt-1 text-[11px] font-bold uppercase tracking-wider text-muted-foreground",
- inset && "pl-8",
- className,
- )}
- {...props}
- />
-));
-DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
-
-const DropdownMenuSeparator = React.forwardRef<
- React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
- React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
->(({ className, ...props }, ref) => (
- <DropdownMenuPrimitive.Separator
- ref={ref}
- className={cn("bg-border/60 mx-1 my-2 h-px", className)}
- {...props}
- />
-));
-DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
-
-const DropdownMenuShortcut = ({
- className,
- ...props
-}: React.HTMLAttributes<HTMLSpanElement>) => {
- return (
- <span
- className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
- {...props}
- />
- );
-};
-DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
-
-export {
- DropdownMenu,
- DropdownMenuTrigger,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuCheckboxItem,
- DropdownMenuRadioItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuShortcut,
- DropdownMenuGroup,
- DropdownMenuPortal,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
- DropdownMenuRadioGroup,
-};
+"use client";
+
+import * as React from "react";
+import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
+import { Check, ChevronRight, Circle } from "lucide-react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/utils/ui";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
+
+function DropdownMenu({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "dropdown-menu",
+ open,
+ onOpenChange,
+ });
+ return (
+ <DropdownMenuPrimitive.Root
+ open={open}
+ onOpenChange={handleOpenChange}
+ {...props}
+ />
+ );
+}
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group;
+
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub;
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
+
+const dropdownMenuItemVariants = cva(
+ "relative flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-sm text-foreground outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "",
+ destructive:
+ "text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
+ inset?: boolean;
+ variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
+ }
+>(({ className, inset, children, variant = "default", ...props }, ref) => (
+ <DropdownMenuPrimitive.SubTrigger
+ ref={ref}
+ className={cn(
+ dropdownMenuItemVariants({ variant }),
+ "data-[state=open]:bg-muted data-[state=open]:text-foreground",
+ inset && "pl-8",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ <ChevronRight className="ml-auto" />
+ </DropdownMenuPrimitive.SubTrigger>
+));
+DropdownMenuSubTrigger.displayName =
+ DropdownMenuPrimitive.SubTrigger.displayName;
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
+>(({ className, ...props }, ref) => (
+ <DropdownMenuPrimitive.SubContent
+ ref={ref}
+ className={cn(
+ "group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg",
+ className,
+ )}
+ {...props}
+ />
+));
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName;
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
+>(({ className, sideOffset = 4, ...props }, ref) => (
+ <DropdownMenuPrimitive.Portal>
+ <DropdownMenuPrimitive.Content
+ ref={ref}
+ sideOffset={sideOffset}
+ onCloseAutoFocus={(e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ }}
+ className={cn(
+ "group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-lg border p-1.5 shadow-lg",
+ className,
+ )}
+ {...props}
+ />
+ </DropdownMenuPrimitive.Portal>
+));
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.Item>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
+ inset?: boolean;
+ icon?: React.ReactNode;
+ variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
+ }
+>(
+ (
+ {
+ className,
+ inset,
+ icon,
+ variant = "default",
+ children,
+ asChild,
+ ...props
+ },
+ ref,
+ ) => {
+ const iconSlot = (
+ <span className="hidden size-4 shrink-0 items-center justify-center group-has-data-has-icon/menu:flex">
+ {icon}
+ </span>
+ );
+
+ const renderedChildren =
+ asChild && React.isValidElement(children) ? (
+ React.cloneElement(
+ children as React.ReactElement<{ children?: React.ReactNode }>,
+ {},
+ iconSlot,
+ (children as React.ReactElement<{ children?: React.ReactNode }>).props
+ .children,
+ )
+ ) : (
+ <>
+ {iconSlot}
+ {children}
+ </>
+ );
+
+ return (
+ <DropdownMenuPrimitive.Item
+ ref={ref}
+ asChild={asChild}
+ data-has-icon={icon ? "" : undefined}
+ className={cn(
+ dropdownMenuItemVariants({ variant }),
+ inset && "pl-8",
+ className,
+ )}
+ {...props}
+ >
+ {renderedChildren}
+ </DropdownMenuPrimitive.Item>
+ );
+ },
+);
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
+ variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
+ }
+>(({ className, children, checked, variant = "default", ...props }, ref) => (
+ <DropdownMenuPrimitive.CheckboxItem
+ ref={ref}
+ className={cn(
+ dropdownMenuItemVariants({ variant }),
+ "pr-8 pl-2",
+ className,
+ )}
+ checked={checked}
+ onSelect={(e) => {
+ e.preventDefault();
+ }}
+ {...props}
+ >
+ {children}
+ <span className="absolute right-2 flex size-3.5 items-center justify-center">
+ <DropdownMenuPrimitive.ItemIndicator>
+ <Check className="size-4" />
+ </DropdownMenuPrimitive.ItemIndicator>
+ </span>
+ </DropdownMenuPrimitive.CheckboxItem>
+));
+
+DropdownMenuCheckboxItem.displayName =
+ DropdownMenuPrimitive.CheckboxItem.displayName;
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
+ variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
+ }
+>(({ className, children, variant = "default", ...props }, ref) => (
+ <DropdownMenuPrimitive.RadioItem
+ ref={ref}
+ className={cn(
+ dropdownMenuItemVariants({ variant }),
+ "pr-2 pl-8",
+ className,
+ )}
+ {...props}
+ >
+ <span className="absolute left-2 flex size-3.5 items-center justify-center">
+ <DropdownMenuPrimitive.ItemIndicator>
+ <Circle className="size-2 fill-current" />
+ </DropdownMenuPrimitive.ItemIndicator>
+ </span>
+ {children}
+ </DropdownMenuPrimitive.RadioItem>
+));
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.Label>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+ <DropdownMenuPrimitive.Label
+ ref={ref}
+ className={cn(
+ "px-3 pb-2 pt-1 text-[11px] font-bold uppercase tracking-wider text-muted-foreground",
+ inset && "pl-8",
+ className,
+ )}
+ {...props}
+ />
+));
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
+>(({ className, ...props }, ref) => (
+ <DropdownMenuPrimitive.Separator
+ ref={ref}
+ className={cn("bg-border/60 mx-1 my-2 h-px", className)}
+ {...props}
+ />
+));
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
+
+const DropdownMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLSpanElement>) => {
+ return (
+ <span
+ className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
+ {...props}
+ />
+ );
+};
+DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+};
diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx
index 77d8ab7f..016bbaf4 100644
--- a/apps/web/src/components/ui/font-picker.tsx
+++ b/apps/web/src/components/ui/font-picker.tsx
@@ -1,258 +1,258 @@
-"use client";
-
-import { useState, useMemo, useRef, useEffect, useCallback, type CSSProperties } from "react";
-import { List, type RowComponentProps } from "react-window";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
-import { Input } from "@/components/ui/input";
-import { Button } from "@/components/ui/button";
-import { loadFullFont } from "@/lib/fonts/google-fonts";
-import { SYSTEM_FONTS } from "@/constants/font-constants";
-import type { FontAtlas, FontAtlasEntry } from "@/lib/fonts/types";
-import { useFontAtlas } from "@/hooks/use-font-atlas";
-import { cn } from "@/utils/ui";
-import { ChevronDown, Search } from "lucide-react";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { TextIcon } from "@hugeicons/core-free-icons";
-
-const FONT_TABS = [
- { key: "all", label: "All fonts" },
- { key: "my-fonts", label: "My fonts" },
- { key: "favorites", label: "Favorites" },
-] as const;
-
-type FontTab = (typeof FONT_TABS)[number]["key"];
-
-const ROW_HEIGHT = 40;
-const PREVIEW_SCALE = 0.8;
-const LIST_WIDTH = 288;
-const MAX_LIST_HEIGHT = 288;
-const OVERSCAN = 15;
-
-interface FontPickerProps {
- defaultValue?: string;
- onValueChange?: (value: string) => void;
- className?: string;
-}
-
-export function FontPicker({
- defaultValue,
- onValueChange,
- className,
-}: FontPickerProps) {
- const [open, setOpen] = useState(false);
- const [search, setSearch] = useState("");
- const [activeTab, setActiveTab] = useState<FontTab>("all");
- const searchInputRef = useRef<HTMLInputElement>(null);
- const { atlas, status, fontNames, retry: handleRetry } = useFontAtlas({ open });
-
- const filteredFonts = useMemo(() => {
- if (!search) return fontNames;
- const query = search.toLowerCase();
- return fontNames.filter((name) => name.toLowerCase().includes(query));
- }, [fontNames, search]);
-
- const listHeight = Math.min(
- MAX_LIST_HEIGHT,
- filteredFonts.length * ROW_HEIGHT,
- );
-
- const handleSelect = useCallback(
- async ({ family }: { family: string }) => {
- if (!SYSTEM_FONTS.has(family)) {
- try {
- await loadFullFont({ family });
- } catch {
- // ignore load failure, font will fall back to system default
- }
- }
- onValueChange?.(family);
- setOpen(false);
- },
- [onValueChange],
- );
-
- useEffect(() => {
- if (!open) {
- setSearch("");
- setActiveTab("all");
- }
- }, [open]);
-
- const activeTabLabel =
- FONT_TABS.find((t) => t.key === activeTab)?.label.toLowerCase() ?? "";
-
- return (
- <Popover open={open} onOpenChange={setOpen}>
- <PopoverTrigger
- className={cn(
- "border-border bg-accent flex h-7 w-full cursor-pointer items-center justify-between gap-1 rounded-md border px-2.5 text-sm whitespace-nowrap focus-visible:border-primary focus-visible:ring-0 focus:outline-hidden",
- className,
- )}
- >
- <div className="flex min-w-0 items-center gap-1.5">
- <span className="text-muted-foreground [&_svg]:size-3.5 shrink-0">
- <HugeiconsIcon icon={TextIcon} />
- </span>
- <span className="truncate" style={{ fontFamily: defaultValue }}>
- {defaultValue ?? "Select a font"}
- </span>
- </div>
- <ChevronDown className="size-3 shrink-0 opacity-50" />
- </PopoverTrigger>
- <PopoverContent
- className="w-72 p-0 overflow-hidden"
- align="start"
- side="left"
- onOpenAutoFocus={(event) => {
- event.preventDefault();
- searchInputRef.current?.focus();
- }}
- onCloseAutoFocus={(event) => {
- event.preventDefault();
- event.stopPropagation();
- }}
- >
- <div className="relative px-3 py-1.5">
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 shrink-0 opacity-50" />
- <Input
- ref={searchInputRef}
- placeholder={`Search ${activeTabLabel}...`}
- value={search}
- onChange={(event) => setSearch(event.target.value)}
- size="xs"
- className="w-full pl-5 bg-transparent border-none! shadow-none!"
- />
- </div>
- <div className="flex border-b px-3">
- {FONT_TABS.map((tab) => (
- <button
- key={tab.key}
- type="button"
- className={cn(
- "px-3 py-1.5 text-xs border-b-2 -mb-px",
- activeTab === tab.key
- ? "border-foreground text-foreground"
- : "border-transparent text-muted-foreground hover:text-foreground",
- )}
- onClick={() => setActiveTab(tab.key)}
- >
- {tab.label}
- </button>
- ))}
- </div>
- {status === "loading" && (
- <div className="py-8 text-center text-sm text-muted-foreground">
- Loading fonts...
- </div>
- )}
- {status === "error" && (
- <div className="flex flex-col items-center gap-3 py-8 px-4">
- <p className="text-sm text-muted-foreground text-center">
- Failed to load font previews.
- </p>
- <Button variant="outline" size="sm" onClick={handleRetry}>
- Retry
- </Button>
- </div>
- )}
- {status === "idle" &&
- fontNames.length > 0 &&
- filteredFonts.length === 0 && (
- <div className="py-6 text-center text-sm text-muted-foreground">
- No fonts found.
- </div>
- )}
- {status === "idle" && atlas && filteredFonts.length > 0 && (
- <List
- rowCount={filteredFonts.length}
- rowHeight={ROW_HEIGHT}
- overscanCount={OVERSCAN}
- rowComponent={FontRow}
- rowProps={{
- atlas,
- filteredFonts,
- selectedFont: defaultValue,
- onFontSelect: handleSelect,
- }}
- style={{ height: listHeight, width: LIST_WIDTH }}
- />
- )}
- </PopoverContent>
- </Popover>
- );
-}
-
-function FontSpritePreview({ entry }: { entry: FontAtlasEntry }) {
- return (
- <div
- className="shrink-0"
- style={{
- width: entry.w,
- height: ROW_HEIGHT,
- backgroundColor: "currentColor",
- WebkitMaskImage: `url(/fonts/font-chunk-${entry.ch}.avif)`,
- WebkitMaskPosition: `-${entry.x}px -${entry.y}px`,
- WebkitMaskRepeat: "no-repeat",
- maskImage: `url(/fonts/font-chunk-${entry.ch}.avif)`,
- maskPosition: `-${entry.x}px -${entry.y}px`,
- maskRepeat: "no-repeat",
- transform: `scale(${PREVIEW_SCALE})`,
- transformOrigin: "left center",
- }}
- />
- );
-}
-
-type FontRowProps = {
- atlas: FontAtlas;
- filteredFonts: string[];
- selectedFont: string | undefined;
- onFontSelect: (params: { family: string }) => void;
-};
-
-function FontRow({
- index,
- style,
- atlas,
- filteredFonts,
- selectedFont,
- onFontSelect,
-}: RowComponentProps<FontRowProps>) {
- const fontName = filteredFonts[index];
- const entry = atlas.fonts[fontName];
- const isSelected = fontName === selectedFont;
- const isSystemFont = SYSTEM_FONTS.has(fontName);
-
- return (
- <button
- type="button"
- style={style as CSSProperties}
- className={cn(
- "flex w-full cursor-pointer items-center gap-2 px-3 outline-hidden hover:bg-popover-hover",
- isSelected && "bg-popover-hover",
- )}
- onClick={() => onFontSelect({ family: fontName })}
- onKeyDown={(event) => {
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- onFontSelect({ family: fontName });
- }
- }}
- aria-label={fontName}
- >
- <div className="min-w-0 overflow-hidden">
- {isSystemFont ? (
- <span className="text-xl text-foreground/85" style={{ fontFamily: fontName }}>
- {fontName}
- </span>
- ) : (
- <FontSpritePreview entry={entry} />
- )}
- </div>
- </button>
- );
-}
+"use client";
+
+import { useState, useMemo, useRef, useEffect, useCallback, type CSSProperties } from "react";
+import { List, type RowComponentProps } from "react-window";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { loadFullFont } from "@/lib/fonts/google-fonts";
+import { SYSTEM_FONTS } from "@/constants/font-constants";
+import type { FontAtlas, FontAtlasEntry } from "@/lib/fonts/types";
+import { useFontAtlas } from "@/hooks/use-font-atlas";
+import { cn } from "@/utils/ui";
+import { ChevronDown, Search } from "lucide-react";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { TextIcon } from "@hugeicons/core-free-icons";
+
+const FONT_TABS = [
+ { key: "all", label: "All fonts" },
+ { key: "my-fonts", label: "My fonts" },
+ { key: "favorites", label: "Favorites" },
+] as const;
+
+type FontTab = (typeof FONT_TABS)[number]["key"];
+
+const ROW_HEIGHT = 40;
+const PREVIEW_SCALE = 0.8;
+const LIST_WIDTH = 288;
+const MAX_LIST_HEIGHT = 288;
+const OVERSCAN = 15;
+
+interface FontPickerProps {
+ defaultValue?: string;
+ onValueChange?: (value: string) => void;
+ className?: string;
+}
+
+export function FontPicker({
+ defaultValue,
+ onValueChange,
+ className,
+}: FontPickerProps) {
+ const [open, setOpen] = useState(false);
+ const [search, setSearch] = useState("");
+ const [activeTab, setActiveTab] = useState<FontTab>("all");
+ const searchInputRef = useRef<HTMLInputElement>(null);
+ const { atlas, status, fontNames, retry: handleRetry } = useFontAtlas({ open });
+
+ const filteredFonts = useMemo(() => {
+ if (!search) return fontNames;
+ const query = search.toLowerCase();
+ return fontNames.filter((name) => name.toLowerCase().includes(query));
+ }, [fontNames, search]);
+
+ const listHeight = Math.min(
+ MAX_LIST_HEIGHT,
+ filteredFonts.length * ROW_HEIGHT,
+ );
+
+ const handleSelect = useCallback(
+ async ({ family }: { family: string }) => {
+ if (!SYSTEM_FONTS.has(family)) {
+ try {
+ await loadFullFont({ family });
+ } catch {
+ // ignore load failure, font will fall back to system default
+ }
+ }
+ onValueChange?.(family);
+ setOpen(false);
+ },
+ [onValueChange],
+ );
+
+ useEffect(() => {
+ if (!open) {
+ setSearch("");
+ setActiveTab("all");
+ }
+ }, [open]);
+
+ const activeTabLabel =
+ FONT_TABS.find((t) => t.key === activeTab)?.label.toLowerCase() ?? "";
+
+ return (
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger
+ className={cn(
+ "border-border bg-accent flex h-7 w-full cursor-pointer items-center justify-between gap-1 rounded-md border px-2.5 text-sm whitespace-nowrap focus-visible:border-primary focus-visible:ring-0 focus:outline-hidden",
+ className,
+ )}
+ >
+ <div className="flex min-w-0 items-center gap-1.5">
+ <span className="text-muted-foreground [&_svg]:size-3.5 shrink-0">
+ <HugeiconsIcon icon={TextIcon} />
+ </span>
+ <span className="truncate" style={{ fontFamily: defaultValue }}>
+ {defaultValue ?? "Select a font"}
+ </span>
+ </div>
+ <ChevronDown className="size-3 shrink-0 opacity-50" />
+ </PopoverTrigger>
+ <PopoverContent
+ className="w-72 p-0 overflow-hidden"
+ align="start"
+ side="left"
+ onOpenAutoFocus={(event) => {
+ event.preventDefault();
+ searchInputRef.current?.focus();
+ }}
+ onCloseAutoFocus={(event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ }}
+ >
+ <div className="relative px-3 py-1.5">
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 shrink-0 opacity-50" />
+ <Input
+ ref={searchInputRef}
+ placeholder={`Search ${activeTabLabel}...`}
+ value={search}
+ onChange={(event) => setSearch(event.target.value)}
+ size="xs"
+ className="w-full pl-5 bg-transparent border-none! shadow-none!"
+ />
+ </div>
+ <div className="flex border-b px-3">
+ {FONT_TABS.map((tab) => (
+ <button
+ key={tab.key}
+ type="button"
+ className={cn(
+ "px-3 py-1.5 text-xs border-b-2 -mb-px",
+ activeTab === tab.key
+ ? "border-foreground text-foreground"
+ : "border-transparent text-muted-foreground hover:text-foreground",
+ )}
+ onClick={() => setActiveTab(tab.key)}
+ >
+ {tab.label}
+ </button>
+ ))}
+ </div>
+ {status === "loading" && (
+ <div className="py-8 text-center text-sm text-muted-foreground">
+ Loading fonts...
+ </div>
+ )}
+ {status === "error" && (
+ <div className="flex flex-col items-center gap-3 py-8 px-4">
+ <p className="text-sm text-muted-foreground text-center">
+ Failed to load font previews.
+ </p>
+ <Button variant="outline" size="sm" onClick={handleRetry}>
+ Retry
+ </Button>
+ </div>
+ )}
+ {status === "idle" &&
+ fontNames.length > 0 &&
+ filteredFonts.length === 0 && (
+ <div className="py-6 text-center text-sm text-muted-foreground">
+ No fonts found.
+ </div>
+ )}
+ {status === "idle" && atlas && filteredFonts.length > 0 && (
+ <List
+ rowCount={filteredFonts.length}
+ rowHeight={ROW_HEIGHT}
+ overscanCount={OVERSCAN}
+ rowComponent={FontRow}
+ rowProps={{
+ atlas,
+ filteredFonts,
+ selectedFont: defaultValue,
+ onFontSelect: handleSelect,
+ }}
+ style={{ height: listHeight, width: LIST_WIDTH }}
+ />
+ )}
+ </PopoverContent>
+ </Popover>
+ );
+}
+
+function FontSpritePreview({ entry }: { entry: FontAtlasEntry }) {
+ return (
+ <div
+ className="shrink-0"
+ style={{
+ width: entry.w,
+ height: ROW_HEIGHT,
+ backgroundColor: "currentColor",
+ WebkitMaskImage: `url(/fonts/font-chunk-${entry.ch}.avif)`,
+ WebkitMaskPosition: `-${entry.x}px -${entry.y}px`,
+ WebkitMaskRepeat: "no-repeat",
+ maskImage: `url(/fonts/font-chunk-${entry.ch}.avif)`,
+ maskPosition: `-${entry.x}px -${entry.y}px`,
+ maskRepeat: "no-repeat",
+ transform: `scale(${PREVIEW_SCALE})`,
+ transformOrigin: "left center",
+ }}
+ />
+ );
+}
+
+type FontRowProps = {
+ atlas: FontAtlas;
+ filteredFonts: string[];
+ selectedFont: string | undefined;
+ onFontSelect: (params: { family: string }) => void;
+};
+
+function FontRow({
+ index,
+ style,
+ atlas,
+ filteredFonts,
+ selectedFont,
+ onFontSelect,
+}: RowComponentProps<FontRowProps>) {
+ const fontName = filteredFonts[index];
+ const entry = atlas.fonts[fontName];
+ const isSelected = fontName === selectedFont;
+ const isSystemFont = SYSTEM_FONTS.has(fontName);
+
+ return (
+ <button
+ type="button"
+ style={style as CSSProperties}
+ className={cn(
+ "flex w-full cursor-pointer items-center gap-2 px-3 outline-hidden hover:bg-popover-hover",
+ isSelected && "bg-popover-hover",
+ )}
+ onClick={() => onFontSelect({ family: fontName })}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ onFontSelect({ family: fontName });
+ }
+ }}
+ aria-label={fontName}
+ >
+ <div className="min-w-0 overflow-hidden">
+ {isSystemFont ? (
+ <span className="text-xl text-foreground/85" style={{ fontFamily: fontName }}>
+ {fontName}
+ </span>
+ ) : (
+ <FontSpritePreview entry={entry} />
+ )}
+ </div>
+ </button>
+ );
+}
diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx
index 1dce2f24..6be8e2bf 100644
--- a/apps/web/src/components/ui/input.tsx
+++ b/apps/web/src/components/ui/input.tsx
@@ -1,143 +1,143 @@
-"use client";
-
-import { Eye, EyeOff, X } from "lucide-react";
-import { cva, type VariantProps } from "class-variance-authority";
-import { cn } from "@/utils/ui";
-import { Button } from "./button";
-import { forwardRef, type ComponentProps } from "react";
-import { useState } from "react";
-
-const inputVariants = cva(
- "file:text-foreground placeholder:text-muted-foreground border-border bg-input flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-offset-0 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
- {
- variants: {
- variant: {
- default: "selection:bg-primary selection:text-primary-foreground focus-visible:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary/20",
- outline: "bg-background",
- destructive:
- "selection:bg-destructive selection:text-destructive-foreground focus-visible:border-destructive focus-visible:ring-destructive/10",
- },
- size: {
- default: "h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm",
- xs: "h-7 px-3 text-xs file:h-6 file:text-xs",
- sm: "h-7 px-3 text-sm file:h-6 file:text-xs",
- lg: "h-10 px-4 text-base file:h-8 file:text-sm md:text-sm",
- },
- },
- defaultVariants: {
- size: "default",
- variant: "default",
- },
- },
-);
-
-interface InputProps
- extends Omit<ComponentProps<"input">, "size">,
- VariantProps<typeof inputVariants> {
- showPassword?: boolean;
- onShowPasswordChange?: (show: boolean) => void;
- showClearIcon?: boolean;
- onClear?: () => void;
- containerClassName?: string;
-}
-
-const Input = forwardRef<HTMLInputElement, InputProps>(
- (
- {
- className,
- type,
- size,
- variant,
- containerClassName,
- showPassword,
- onShowPasswordChange,
- showClearIcon,
- onClear,
- value,
- onFocus,
- onBlur,
- ...props
- },
- ref,
- ) => {
- const [isFocused, setIsFocused] = useState(false);
-
- const isPassword = type === "password";
- const showPasswordToggle = isPassword && onShowPasswordChange;
- const showClear =
- showClearIcon &&
- onClear &&
- value &&
- String(value).length > 0 &&
- isFocused;
- const inputType = isPassword && showPassword ? "text" : type;
-
- const hasIcons = showPasswordToggle || showClear;
- const iconCount = Number(showPasswordToggle) + Number(showClear);
- const paddingRight =
- iconCount === 2 ? "pr-20" : iconCount === 1 ? "pr-10" : "";
-
- return (
- <div
- className={cn(hasIcons ? "relative w-full" : "", containerClassName)}
- >
- <input
- type={inputType}
- className={cn(
- inputVariants({
- size,
- className: cn(paddingRight, className),
- variant,
- }),
- )}
- ref={ref}
- value={value}
- onFocus={(e) => {
- setIsFocused(true);
- onFocus?.(e);
- }}
- onBlur={(e) => {
- setIsFocused(false);
- onBlur?.(e);
- }}
- {...props}
- />
- {showClear && (
- <Button
- variant="text"
- size="icon"
- onMouseDown={(e) => {
- e.preventDefault();
- onClear?.();
- }}
- className="text-muted-foreground absolute top-0 right-0 h-full px-3 !opacity-100"
- aria-label="Clear input"
- >
- <X className="!size-[0.85]" />
- </Button>
- )}
- {showPasswordToggle && (
- <Button
- variant="text"
- size="icon"
- onClick={() => onShowPasswordChange?.(!showPassword)}
- className={cn(
- "text-muted-foreground hover:text-foreground absolute top-0 h-full px-3",
- showClear ? "right-10" : "right-0",
- )}
- aria-label={showPassword ? "Hide password" : "Show password"}
- >
- {showPassword ? (
- <Eye className="size-4" />
- ) : (
- <EyeOff className="size-4" />
- )}
- </Button>
- )}
- </div>
- );
- },
-);
-Input.displayName = "Input";
-
-export { Input };
+"use client";
+
+import { Eye, EyeOff, X } from "lucide-react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/utils/ui";
+import { Button } from "./button";
+import { forwardRef, type ComponentProps } from "react";
+import { useState } from "react";
+
+const inputVariants = cva(
+ "file:text-foreground placeholder:text-muted-foreground border-border bg-input flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-offset-0 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: "selection:bg-primary selection:text-primary-foreground focus-visible:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary/20",
+ outline: "bg-background",
+ destructive:
+ "selection:bg-destructive selection:text-destructive-foreground focus-visible:border-destructive focus-visible:ring-destructive/10",
+ },
+ size: {
+ default: "h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm",
+ xs: "h-7 px-3 text-xs file:h-6 file:text-xs",
+ sm: "h-7 px-3 text-sm file:h-6 file:text-xs",
+ lg: "h-10 px-4 text-base file:h-8 file:text-sm md:text-sm",
+ },
+ },
+ defaultVariants: {
+ size: "default",
+ variant: "default",
+ },
+ },
+);
+
+interface InputProps
+ extends Omit<ComponentProps<"input">, "size">,
+ VariantProps<typeof inputVariants> {
+ showPassword?: boolean;
+ onShowPasswordChange?: (show: boolean) => void;
+ showClearIcon?: boolean;
+ onClear?: () => void;
+ containerClassName?: string;
+}
+
+const Input = forwardRef<HTMLInputElement, InputProps>(
+ (
+ {
+ className,
+ type,
+ size,
+ variant,
+ containerClassName,
+ showPassword,
+ onShowPasswordChange,
+ showClearIcon,
+ onClear,
+ value,
+ onFocus,
+ onBlur,
+ ...props
+ },
+ ref,
+ ) => {
+ const [isFocused, setIsFocused] = useState(false);
+
+ const isPassword = type === "password";
+ const showPasswordToggle = isPassword && onShowPasswordChange;
+ const showClear =
+ showClearIcon &&
+ onClear &&
+ value &&
+ String(value).length > 0 &&
+ isFocused;
+ const inputType = isPassword && showPassword ? "text" : type;
+
+ const hasIcons = showPasswordToggle || showClear;
+ const iconCount = Number(showPasswordToggle) + Number(showClear);
+ const paddingRight =
+ iconCount === 2 ? "pr-20" : iconCount === 1 ? "pr-10" : "";
+
+ return (
+ <div
+ className={cn(hasIcons ? "relative w-full" : "", containerClassName)}
+ >
+ <input
+ type={inputType}
+ className={cn(
+ inputVariants({
+ size,
+ className: cn(paddingRight, className),
+ variant,
+ }),
+ )}
+ ref={ref}
+ value={value}
+ onFocus={(e) => {
+ setIsFocused(true);
+ onFocus?.(e);
+ }}
+ onBlur={(e) => {
+ setIsFocused(false);
+ onBlur?.(e);
+ }}
+ {...props}
+ />
+ {showClear && (
+ <Button
+ variant="text"
+ size="icon"
+ onMouseDown={(e) => {
+ e.preventDefault();
+ onClear?.();
+ }}
+ className="text-muted-foreground absolute top-0 right-0 h-full px-3 !opacity-100"
+ aria-label="Clear input"
+ >
+ <X className="!size-[0.85]" />
+ </Button>
+ )}
+ {showPasswordToggle && (
+ <Button
+ variant="text"
+ size="icon"
+ onClick={() => onShowPasswordChange?.(!showPassword)}
+ className={cn(
+ "text-muted-foreground hover:text-foreground absolute top-0 h-full px-3",
+ showClear ? "right-10" : "right-0",
+ )}
+ aria-label={showPassword ? "Hide password" : "Show password"}
+ >
+ {showPassword ? (
+ <Eye className="size-4" />
+ ) : (
+ <EyeOff className="size-4" />
+ )}
+ </Button>
+ )}
+ </div>
+ );
+ },
+);
+Input.displayName = "Input";
+
+export { Input };
diff --git a/apps/web/src/components/ui/number-field.tsx b/apps/web/src/components/ui/number-field.tsx
index acedff10..eae16b9b 100644
--- a/apps/web/src/components/ui/number-field.tsx
+++ b/apps/web/src/components/ui/number-field.tsx
@@ -1,318 +1,318 @@
-"use client";
-
-import { cn } from "@/utils/ui";
-import { clamp } from "@/utils/math";
-import { useRef, useState, useLayoutEffect, type ComponentProps } from "react";
-import { useFocusLock } from "@/hooks/use-focus-lock";
-import { Button } from "@/components/ui/button";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { ArrowTurnBackwardIcon } from "@hugeicons/core-free-icons";
-
-const SUFFIX_GAP_PX = 6;
-
-const DRAG_SENSITIVITIES = {
- default: 1,
- slow: 0.5,
-} as const;
-
-type DragSensitivity = "default" | "slow";
-
-type ScrubRange = {
- from: number;
- to: number;
- pixelsPerUnit: number;
-};
-
-type ScrubClamp = {
- min?: number;
- max?: number;
-};
-
-function clampScrubValue({
- value,
- min,
- max,
-}: {
- value: number;
- min?: number;
- max?: number;
-}): number {
- if (min != null && max != null) return clamp({ value, min, max });
- if (min != null) return Math.max(min, value);
- if (max != null) return Math.min(max, value);
- return value;
-}
-
-function getActiveRange({
- value,
- direction,
- ranges,
-}: {
- value: number;
- direction: number;
- ranges: readonly ScrubRange[];
-}): ScrubRange | undefined {
- return ranges.find((range) =>
- direction > 0
- ? value >= range.from && value < range.to
- : value > range.from && value <= range.to,
- );
-}
-
-function scrubAcrossRanges({
- startValue,
- pixelDelta,
- ranges,
- min,
- max,
-}: {
- startValue: number;
- pixelDelta: number;
- ranges: readonly ScrubRange[];
- min?: number;
- max?: number;
-}): number {
- let currentValue = clampScrubValue({ value: startValue, min, max });
- let remainingPixels = pixelDelta;
-
- while (remainingPixels !== 0) {
- const direction = Math.sign(remainingPixels);
-
- const range = getActiveRange({ value: currentValue, direction, ranges });
- if (!range) break;
-
- const boundary = direction > 0 ? range.to : range.from;
- const pixelsToBoundary =
- Math.abs(boundary - currentValue) * range.pixelsPerUnit;
-
- if (Math.abs(remainingPixels) <= pixelsToBoundary) {
- currentValue += remainingPixels / range.pixelsPerUnit;
- break;
- }
-
- currentValue = boundary;
- remainingPixels -= direction * pixelsToBoundary;
- }
-
- return clampScrubValue({ value: currentValue, min, max });
-}
-
-interface NumberFieldProps
- extends Omit<ComponentProps<"input">, "size" | "type"> {
- icon?: React.ReactNode;
- suffix?: string;
- suffixClassName?: string;
- dragSensitivity?: DragSensitivity;
- scrubRanges?: readonly ScrubRange[];
- scrubClamp?: ScrubClamp;
- onScrub?: (value: number) => void;
- onScrubEnd?: () => void;
- allowExpressions?: boolean;
- onReset?: () => void;
- isDefault?: boolean;
-}
-
-function NumberField({
- className,
- icon,
- suffix,
- suffixClassName,
- disabled,
- dragSensitivity = "default",
- scrubRanges,
- scrubClamp,
- onScrub,
- onScrubEnd,
- value,
- allowExpressions = true,
- onKeyDown,
- onFocus,
- onBlur,
- onMouseDown,
- onReset,
- isDefault = false,
- ref,
- ...props
-}: NumberFieldProps & { ref?: React.Ref<HTMLInputElement> }) {
- const iconRef = useRef<HTMLButtonElement>(null);
- const inputRef = useRef<HTMLInputElement>(null);
- const ghostRef = useRef<HTMLSpanElement>(null);
- const startValueRef = useRef(0);
- const cumulativeDeltaRef = useRef(0);
- const [isInputFocused, setIsInputFocused] = useState(false);
- const [suffixLeft, setSuffixLeft] = useState(0);
- const ghostValue = Array.isArray(value) ? value.join(", ") : String(value ?? "");
-
- useLayoutEffect(() => {
- if (!suffix) {
- setSuffixLeft(0);
- return;
- }
- if (!ghostRef.current || !inputRef.current) return;
- if (ghostRef.current.textContent !== ghostValue) {
- ghostRef.current.textContent = ghostValue;
- }
- const paddingLeft =
- parseFloat(getComputedStyle(inputRef.current).paddingLeft) || 0;
- setSuffixLeft(paddingLeft + ghostRef.current.offsetWidth);
- }, [ghostValue, suffix]);
-
- const { containerRef: wrapperRef } = useFocusLock<HTMLDivElement>({
- isActive: isInputFocused,
- onDismiss: () => inputRef.current?.blur(),
- cursor: "text",
- allowSelector: "input, textarea, [contenteditable]",
- });
-
- const handleIconPointerDown = (event: React.PointerEvent) => {
- if (!onScrub || disabled || event.button !== 0) return;
- const parsed = parseFloat(String(value ?? "0"));
- startValueRef.current = Number.isNaN(parsed) ? 0 : parsed;
- cumulativeDeltaRef.current = 0;
- let hasReceivedFirstMove = false;
- iconRef.current?.requestPointerLock();
-
- const handlePointerMove = (moveEvent: PointerEvent) => {
- // first movementX after pointer lock often contains a bogus warp delta
- if (!hasReceivedFirstMove) {
- hasReceivedFirstMove = true;
- return;
- }
- cumulativeDeltaRef.current += moveEvent.movementX;
- const newValue = scrubRanges
- ? scrubAcrossRanges({
- startValue: startValueRef.current,
- pixelDelta: cumulativeDeltaRef.current,
- ranges: scrubRanges,
- min: scrubClamp?.min,
- max: scrubClamp?.max,
- })
- : startValueRef.current +
- cumulativeDeltaRef.current * DRAG_SENSITIVITIES[dragSensitivity];
- onScrub(newValue);
- };
-
- const handlePointerUp = () => {
- document.removeEventListener("pointermove", handlePointerMove);
- document.removeEventListener("pointerup", handlePointerUp);
- document.exitPointerLock();
- onScrubEnd?.();
- };
-
- document.addEventListener("pointermove", handlePointerMove);
- document.addEventListener("pointerup", handlePointerUp);
- };
-
- const canScrub = Boolean(icon && onScrub);
-
- const inputNode = (
- <input
- type={allowExpressions ? "text" : "number"}
- inputMode={allowExpressions ? "decimal" : undefined}
- ref={inputRef}
- disabled={disabled}
- value={value}
- className="text-sm leading-none bg-transparent outline-none min-w-0 flex-1 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
- onMouseDown={(event) => {
- const inputElement = event.currentTarget;
- const shouldPreventNativeCaretPlacement =
- event.button === 0 && document.activeElement !== inputElement;
- if (shouldPreventNativeCaretPlacement) {
- event.preventDefault();
- inputElement.focus();
- inputElement.select();
- }
- onMouseDown?.(event);
- }}
- onFocus={(event) => {
- setIsInputFocused(true);
- event.currentTarget.select();
- onFocus?.(event);
- }}
- onKeyDown={(event) => {
- const shouldBlurInput = event.key === "Enter" || event.key === "Escape";
- if (shouldBlurInput) event.currentTarget.blur();
- onKeyDown?.(event);
- }}
- onBlur={(event) => {
- setIsInputFocused(false);
- onBlur?.(event);
- }}
- {...props}
- />
- );
-
- return (
- <div
- ref={wrapperRef}
- className={cn(
- "border-border bg-accent flex h-7 w-full min-w-0 items-center rounded-md border text-sm outline-none cursor-text disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-within:border-primary focus-within:ring-0 focus-within:ring-primary/10 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
- disabled && "pointer-events-none cursor-not-allowed opacity-50",
- className,
- )}
- >
- {icon &&
- (canScrub ? (
- <button
- ref={iconRef}
- type="button"
- aria-label="Drag to adjust value"
- disabled={disabled}
- className="text-muted-foreground [&_svg]:size-3.5! shrink-0 select-none pl-2.5 text-sm leading-none cursor-ew-resize"
- onMouseDown={(event) => event.preventDefault()}
- onPointerDown={handleIconPointerDown}
- >
- {icon}
- </button>
- ) : (
- <span className="text-muted-foreground [&_svg]:size-3.5! shrink-0 select-none pl-2.5 text-sm leading-none">
- {icon}
- </span>
- ))}
- <span
- className={cn(
- "relative flex flex-1 min-w-0 items-center",
- icon ? "px-1.5" : "pl-2.5",
- onReset ? "pr-0" : "pr-2.5",
- )}
- >
- {inputNode}
- {suffix && (
- <>
- {/* Ghost mirrors value text to measure width for suffix positioning */}
- <span
- ref={ghostRef}
- className="invisible absolute text-sm leading-none whitespace-pre pointer-events-none"
- aria-hidden="true"
- >
- {ghostValue}
- </span>
- <span
- className={cn(
- "absolute top-1/2 -translate-y-1/2 select-none pointer-events-none text-sm leading-none",
- suffixClassName,
- )}
- style={{ left: suffixLeft + SUFFIX_GAP_PX }}
- >
- {suffix}
- </span>
- </>
- )}
- </span>
- {onReset && !isDefault && (
- <div className="shrink-0 pr-2 flex items-center">
- <Button
- variant="text"
- size="text"
- aria-label="Reset to default"
- onClick={onReset}
- >
- <HugeiconsIcon icon={ArrowTurnBackwardIcon} className="size-3.5!" />
- </Button>
- </div>
- )}
- </div>
- );
-}
-
-export { NumberField };
+"use client";
+
+import { cn } from "@/utils/ui";
+import { clamp } from "@/utils/math";
+import { useRef, useState, useLayoutEffect, type ComponentProps } from "react";
+import { useFocusLock } from "@/hooks/use-focus-lock";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { ArrowTurnBackwardIcon } from "@hugeicons/core-free-icons";
+
+const SUFFIX_GAP_PX = 6;
+
+const DRAG_SENSITIVITIES = {
+ default: 1,
+ slow: 0.5,
+} as const;
+
+type DragSensitivity = "default" | "slow";
+
+type ScrubRange = {
+ from: number;
+ to: number;
+ pixelsPerUnit: number;
+};
+
+type ScrubClamp = {
+ min?: number;
+ max?: number;
+};
+
+function clampScrubValue({
+ value,
+ min,
+ max,
+}: {
+ value: number;
+ min?: number;
+ max?: number;
+}): number {
+ if (min != null && max != null) return clamp({ value, min, max });
+ if (min != null) return Math.max(min, value);
+ if (max != null) return Math.min(max, value);
+ return value;
+}
+
+function getActiveRange({
+ value,
+ direction,
+ ranges,
+}: {
+ value: number;
+ direction: number;
+ ranges: readonly ScrubRange[];
+}): ScrubRange | undefined {
+ return ranges.find((range) =>
+ direction > 0
+ ? value >= range.from && value < range.to
+ : value > range.from && value <= range.to,
+ );
+}
+
+function scrubAcrossRanges({
+ startValue,
+ pixelDelta,
+ ranges,
+ min,
+ max,
+}: {
+ startValue: number;
+ pixelDelta: number;
+ ranges: readonly ScrubRange[];
+ min?: number;
+ max?: number;
+}): number {
+ let currentValue = clampScrubValue({ value: startValue, min, max });
+ let remainingPixels = pixelDelta;
+
+ while (remainingPixels !== 0) {
+ const direction = Math.sign(remainingPixels);
+
+ const range = getActiveRange({ value: currentValue, direction, ranges });
+ if (!range) break;
+
+ const boundary = direction > 0 ? range.to : range.from;
+ const pixelsToBoundary =
+ Math.abs(boundary - currentValue) * range.pixelsPerUnit;
+
+ if (Math.abs(remainingPixels) <= pixelsToBoundary) {
+ currentValue += remainingPixels / range.pixelsPerUnit;
+ break;
+ }
+
+ currentValue = boundary;
+ remainingPixels -= direction * pixelsToBoundary;
+ }
+
+ return clampScrubValue({ value: currentValue, min, max });
+}
+
+interface NumberFieldProps
+ extends Omit<ComponentProps<"input">, "size" | "type"> {
+ icon?: React.ReactNode;
+ suffix?: string;
+ suffixClassName?: string;
+ dragSensitivity?: DragSensitivity;
+ scrubRanges?: readonly ScrubRange[];
+ scrubClamp?: ScrubClamp;
+ onScrub?: (value: number) => void;
+ onScrubEnd?: () => void;
+ allowExpressions?: boolean;
+ onReset?: () => void;
+ isDefault?: boolean;
+}
+
+function NumberField({
+ className,
+ icon,
+ suffix,
+ suffixClassName,
+ disabled,
+ dragSensitivity = "default",
+ scrubRanges,
+ scrubClamp,
+ onScrub,
+ onScrubEnd,
+ value,
+ allowExpressions = true,
+ onKeyDown,
+ onFocus,
+ onBlur,
+ onMouseDown,
+ onReset,
+ isDefault = false,
+ ref,
+ ...props
+}: NumberFieldProps & { ref?: React.Ref<HTMLInputElement> }) {
+ const iconRef = useRef<HTMLButtonElement>(null);
+ const inputRef = useRef<HTMLInputElement>(null);
+ const ghostRef = useRef<HTMLSpanElement>(null);
+ const startValueRef = useRef(0);
+ const cumulativeDeltaRef = useRef(0);
+ const [isInputFocused, setIsInputFocused] = useState(false);
+ const [suffixLeft, setSuffixLeft] = useState(0);
+ const ghostValue = Array.isArray(value) ? value.join(", ") : String(value ?? "");
+
+ useLayoutEffect(() => {
+ if (!suffix) {
+ setSuffixLeft(0);
+ return;
+ }
+ if (!ghostRef.current || !inputRef.current) return;
+ if (ghostRef.current.textContent !== ghostValue) {
+ ghostRef.current.textContent = ghostValue;
+ }
+ const paddingLeft =
+ parseFloat(getComputedStyle(inputRef.current).paddingLeft) || 0;
+ setSuffixLeft(paddingLeft + ghostRef.current.offsetWidth);
+ }, [ghostValue, suffix]);
+
+ const { containerRef: wrapperRef } = useFocusLock<HTMLDivElement>({
+ isActive: isInputFocused,
+ onDismiss: () => inputRef.current?.blur(),
+ cursor: "text",
+ allowSelector: "input, textarea, [contenteditable]",
+ });
+
+ const handleIconPointerDown = (event: React.PointerEvent) => {
+ if (!onScrub || disabled || event.button !== 0) return;
+ const parsed = parseFloat(String(value ?? "0"));
+ startValueRef.current = Number.isNaN(parsed) ? 0 : parsed;
+ cumulativeDeltaRef.current = 0;
+ let hasReceivedFirstMove = false;
+ iconRef.current?.requestPointerLock();
+
+ const handlePointerMove = (moveEvent: PointerEvent) => {
+ // first movementX after pointer lock often contains a bogus warp delta
+ if (!hasReceivedFirstMove) {
+ hasReceivedFirstMove = true;
+ return;
+ }
+ cumulativeDeltaRef.current += moveEvent.movementX;
+ const newValue = scrubRanges
+ ? scrubAcrossRanges({
+ startValue: startValueRef.current,
+ pixelDelta: cumulativeDeltaRef.current,
+ ranges: scrubRanges,
+ min: scrubClamp?.min,
+ max: scrubClamp?.max,
+ })
+ : startValueRef.current +
+ cumulativeDeltaRef.current * DRAG_SENSITIVITIES[dragSensitivity];
+ onScrub(newValue);
+ };
+
+ const handlePointerUp = () => {
+ document.removeEventListener("pointermove", handlePointerMove);
+ document.removeEventListener("pointerup", handlePointerUp);
+ document.exitPointerLock();
+ onScrubEnd?.();
+ };
+
+ document.addEventListener("pointermove", handlePointerMove);
+ document.addEventListener("pointerup", handlePointerUp);
+ };
+
+ const canScrub = Boolean(icon && onScrub);
+
+ const inputNode = (
+ <input
+ type={allowExpressions ? "text" : "number"}
+ inputMode={allowExpressions ? "decimal" : undefined}
+ ref={inputRef}
+ disabled={disabled}
+ value={value}
+ className="text-sm leading-none bg-transparent outline-none min-w-0 flex-1 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
+ onMouseDown={(event) => {
+ const inputElement = event.currentTarget;
+ const shouldPreventNativeCaretPlacement =
+ event.button === 0 && document.activeElement !== inputElement;
+ if (shouldPreventNativeCaretPlacement) {
+ event.preventDefault();
+ inputElement.focus();
+ inputElement.select();
+ }
+ onMouseDown?.(event);
+ }}
+ onFocus={(event) => {
+ setIsInputFocused(true);
+ event.currentTarget.select();
+ onFocus?.(event);
+ }}
+ onKeyDown={(event) => {
+ const shouldBlurInput = event.key === "Enter" || event.key === "Escape";
+ if (shouldBlurInput) event.currentTarget.blur();
+ onKeyDown?.(event);
+ }}
+ onBlur={(event) => {
+ setIsInputFocused(false);
+ onBlur?.(event);
+ }}
+ {...props}
+ />
+ );
+
+ return (
+ <div
+ ref={wrapperRef}
+ className={cn(
+ "border-border bg-accent flex h-7 w-full min-w-0 items-center rounded-md border text-sm outline-none cursor-text disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-within:border-primary focus-within:ring-0 focus-within:ring-primary/10 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
+ disabled && "pointer-events-none cursor-not-allowed opacity-50",
+ className,
+ )}
+ >
+ {icon &&
+ (canScrub ? (
+ <button
+ ref={iconRef}
+ type="button"
+ aria-label="Drag to adjust value"
+ disabled={disabled}
+ className="text-muted-foreground [&_svg]:size-3.5! shrink-0 select-none pl-2.5 text-sm leading-none cursor-ew-resize"
+ onMouseDown={(event) => event.preventDefault()}
+ onPointerDown={handleIconPointerDown}
+ >
+ {icon}
+ </button>
+ ) : (
+ <span className="text-muted-foreground [&_svg]:size-3.5! shrink-0 select-none pl-2.5 text-sm leading-none">
+ {icon}
+ </span>
+ ))}
+ <span
+ className={cn(
+ "relative flex flex-1 min-w-0 items-center",
+ icon ? "px-1.5" : "pl-2.5",
+ onReset ? "pr-0" : "pr-2.5",
+ )}
+ >
+ {inputNode}
+ {suffix && (
+ <>
+ {/* Ghost mirrors value text to measure width for suffix positioning */}
+ <span
+ ref={ghostRef}
+ className="invisible absolute text-sm leading-none whitespace-pre pointer-events-none"
+ aria-hidden="true"
+ >
+ {ghostValue}
+ </span>
+ <span
+ className={cn(
+ "absolute top-1/2 -translate-y-1/2 select-none pointer-events-none text-sm leading-none",
+ suffixClassName,
+ )}
+ style={{ left: suffixLeft + SUFFIX_GAP_PX }}
+ >
+ {suffix}
+ </span>
+ </>
+ )}
+ </span>
+ {onReset && !isDefault && (
+ <div className="shrink-0 pr-2 flex items-center">
+ <Button
+ variant="text"
+ size="text"
+ aria-label="Reset to default"
+ onClick={onReset}
+ >
+ <HugeiconsIcon icon={ArrowTurnBackwardIcon} className="size-3.5!" />
+ </Button>
+ </div>
+ )}
+ </div>
+ );
+}
+
+export { NumberField };
diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx
index cf480c8c..5a0495fc 100644
--- a/apps/web/src/components/ui/popover.tsx
+++ b/apps/web/src/components/ui/popover.tsx
@@ -1,52 +1,52 @@
-"use client";
-
-import * as React from "react";
-import { Popover as PopoverPrimitive } from "radix-ui";
-import { cn } from "@/utils/ui";
-import { useOverlayOpenChange } from "./use-overlay-open-change";
-
-function Popover({
- open,
- onOpenChange,
- ...props
-}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
- const handleOpenChange = useOverlayOpenChange({
- source: "popover",
- open,
- onOpenChange,
- });
- return (
- <PopoverPrimitive.Root
- open={open}
- onOpenChange={handleOpenChange}
- {...props}
- />
- );
-}
-
-const PopoverTrigger = PopoverPrimitive.Trigger;
-
-const PopoverAnchor = PopoverPrimitive.Anchor;
-
-const PopoverClose = PopoverPrimitive.Close;
-
-const PopoverContent = React.forwardRef<
- React.ElementRef<typeof PopoverPrimitive.Content>,
- React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
->(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
- <PopoverPrimitive.Portal>
- <PopoverPrimitive.Content
- ref={ref}
- align={align}
- sideOffset={sideOffset}
- className={cn(
- "bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-[0_0_10px_rgba(0,0,0,0.15)] outline-hidden",
- className,
- )}
- {...props}
- />
- </PopoverPrimitive.Portal>
-));
-PopoverContent.displayName = PopoverPrimitive.Content.displayName;
-
-export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverClose };
+"use client";
+
+import * as React from "react";
+import { Popover as PopoverPrimitive } from "radix-ui";
+import { cn } from "@/utils/ui";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
+
+function Popover({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "popover",
+ open,
+ onOpenChange,
+ });
+ return (
+ <PopoverPrimitive.Root
+ open={open}
+ onOpenChange={handleOpenChange}
+ {...props}
+ />
+ );
+}
+
+const PopoverTrigger = PopoverPrimitive.Trigger;
+
+const PopoverAnchor = PopoverPrimitive.Anchor;
+
+const PopoverClose = PopoverPrimitive.Close;
+
+const PopoverContent = React.forwardRef<
+ React.ElementRef<typeof PopoverPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
+>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
+ <PopoverPrimitive.Portal>
+ <PopoverPrimitive.Content
+ ref={ref}
+ align={align}
+ sideOffset={sideOffset}
+ className={cn(
+ "bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-[0_0_10px_rgba(0,0,0,0.15)] outline-hidden",
+ className,
+ )}
+ {...props}
+ />
+ </PopoverPrimitive.Portal>
+));
+PopoverContent.displayName = PopoverPrimitive.Content.displayName;
+
+export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverClose };
diff --git a/apps/web/src/components/ui/prose.tsx b/apps/web/src/components/ui/prose.tsx
index 88d017fe..74bb14fd 100644
--- a/apps/web/src/components/ui/prose.tsx
+++ b/apps/web/src/components/ui/prose.tsx
@@ -1,111 +1,111 @@
-import { cn } from "@/utils/ui";
-import rehypeParse from "rehype-parse";
-import { unified } from "unified";
-import { createElement } from "react";
-import type React from "react";
-
-type ProseProps = React.HTMLAttributes<HTMLElement> & {
- as?: "article";
- html: string;
-};
-
-type HastTextNode = {
- type: "text";
- value: string;
-};
-
-type HastElementNode = {
- type: "element";
- tagName: string;
- properties?: Record<string, unknown>;
- children?: HastNode[];
-};
-
-type HastRootNode = {
- type: "root";
- children?: HastNode[];
-};
-
-type HastNode = HastTextNode | HastElementNode | HastRootNode;
-
-function toReactProps({
- properties,
-}: {
- properties: HastElementNode["properties"];
-}): Record<string, unknown> {
- if (!properties) {
- return {};
- }
-
- const props: Record<string, unknown> = {};
-
- for (const [propertyName, propertyValue] of Object.entries(properties)) {
- if (propertyValue === null || propertyValue === undefined) {
- continue;
- }
-
- if (propertyName.startsWith("on")) {
- continue;
- }
-
- if (propertyName === "class") {
- props.className = Array.isArray(propertyValue)
- ? propertyValue.join(" ")
- : propertyValue;
- continue;
- }
-
- if (propertyName === "for") {
- props.htmlFor = propertyValue;
- continue;
- }
-
- props[propertyName] = propertyValue;
- }
-
- return props;
-}
-
-function renderHastNode({
- node,
- key,
-}: {
- node: HastNode;
- key: string;
-}): React.ReactNode {
- if (node.type === "text") {
- return node.value;
- }
-
- if (node.type !== "element") {
- return null;
- }
-
- const children = (node.children ?? []).map((childNode, index) =>
- renderHastNode({ node: childNode, key: `${key}-${index}` }),
- );
-
- return createElement(node.tagName, { ...toReactProps({ properties: node.properties }), key }, children);
-}
-
-function renderHtmlNodes({ html }: { html: string }): React.ReactNode {
- const rootNode = unified().use(rehypeParse, { fragment: true }).parse(html) as HastRootNode;
- return (rootNode.children ?? []).map((childNode, index) =>
- renderHastNode({ node: childNode, key: `prose-node-${index}` }),
- );
-}
-
-function Prose({ children, html, className }: ProseProps) {
- return (
- <article
- className={cn(
- "prose prose-h2:font-semibold prose-h1:text-xl prose-a:text-blue-600 prose-p:first:mt-0 dark:prose-invert mx-auto max-w-none px-2",
- className,
- )}
- >
- {html ? renderHtmlNodes({ html }) : children}
- </article>
- );
-}
-
-export default Prose;
+import { cn } from "@/utils/ui";
+import rehypeParse from "rehype-parse";
+import { unified } from "unified";
+import { createElement } from "react";
+import type React from "react";
+
+type ProseProps = React.HTMLAttributes<HTMLElement> & {
+ as?: "article";
+ html: string;
+};
+
+type HastTextNode = {
+ type: "text";
+ value: string;
+};
+
+type HastElementNode = {
+ type: "element";
+ tagName: string;
+ properties?: Record<string, unknown>;
+ children?: HastNode[];
+};
+
+type HastRootNode = {
+ type: "root";
+ children?: HastNode[];
+};
+
+type HastNode = HastTextNode | HastElementNode | HastRootNode;
+
+function toReactProps({
+ properties,
+}: {
+ properties: HastElementNode["properties"];
+}): Record<string, unknown> {
+ if (!properties) {
+ return {};
+ }
+
+ const props: Record<string, unknown> = {};
+
+ for (const [propertyName, propertyValue] of Object.entries(properties)) {
+ if (propertyValue === null || propertyValue === undefined) {
+ continue;
+ }
+
+ if (propertyName.startsWith("on")) {
+ continue;
+ }
+
+ if (propertyName === "class") {
+ props.className = Array.isArray(propertyValue)
+ ? propertyValue.join(" ")
+ : propertyValue;
+ continue;
+ }
+
+ if (propertyName === "for") {
+ props.htmlFor = propertyValue;
+ continue;
+ }
+
+ props[propertyName] = propertyValue;
+ }
+
+ return props;
+}
+
+function renderHastNode({
+ node,
+ key,
+}: {
+ node: HastNode;
+ key: string;
+}): React.ReactNode {
+ if (node.type === "text") {
+ return node.value;
+ }
+
+ if (node.type !== "element") {
+ return null;
+ }
+
+ const children = (node.children ?? []).map((childNode, index) =>
+ renderHastNode({ node: childNode, key: `${key}-${index}` }),
+ );
+
+ return createElement(node.tagName, { ...toReactProps({ properties: node.properties }), key }, children);
+}
+
+function renderHtmlNodes({ html }: { html: string }): React.ReactNode {
+ const rootNode = unified().use(rehypeParse, { fragment: true }).parse(html) as HastRootNode;
+ return (rootNode.children ?? []).map((childNode, index) =>
+ renderHastNode({ node: childNode, key: `prose-node-${index}` }),
+ );
+}
+
+function Prose({ children, html, className }: ProseProps) {
+ return (
+ <article
+ className={cn(
+ "prose prose-h2:font-semibold prose-h1:text-xl prose-a:text-blue-600 prose-p:first:mt-0 dark:prose-invert mx-auto max-w-none px-2",
+ className,
+ )}
+ >
+ {html ? renderHtmlNodes({ html }) : children}
+ </article>
+ );
+}
+
+export default Prose;
diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx
index b7092b87..b53dada1 100644
--- a/apps/web/src/components/ui/select.tsx
+++ b/apps/web/src/components/ui/select.tsx
@@ -1,221 +1,221 @@
-"use client";
-
-import * as React from "react";
-import { Select as SelectPrimitive } from "radix-ui";
-import { Check } from "lucide-react";
-import { ArrowUpIcon, ArrowDownIcon } from "@hugeicons/core-free-icons";
-import { cva, type VariantProps } from "class-variance-authority";
-import { cn } from "@/utils/ui";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { useOverlayOpenChange } from "./use-overlay-open-change";
-
-function Select({
- open,
- onOpenChange,
- ...props
-}: React.ComponentProps<typeof SelectPrimitive.Root>) {
- const handleOpenChange = useOverlayOpenChange({
- source: "select",
- open,
- onOpenChange,
- });
- return (
- <SelectPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props} />
- );
-}
-
-const SelectGroup = SelectPrimitive.Group;
-
-const SelectValue = SelectPrimitive.Value;
-
-const selectItemVariants = cva(
- "relative flex cursor-pointer select-none items-center gap-1.5 rounded-sm px-2 py-1 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
- {
- variants: {
- variant: {
- default: "",
- destructive:
- "text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive",
- },
- },
- defaultVariants: {
- variant: "default",
- },
- },
-);
-
-const selectTriggerVariants = cva(
- "border-border ring-offset-background placeholder:text-muted-foreground flex h-7 w-auto cursor-pointer items-center justify-between gap-1 rounded-md border px-2.5 text-sm whitespace-nowrap transition-none focus:border-primary focus:ring-0 focus:ring-primary/10 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
- {
- variants: {
- variant: {
- default: "bg-accent",
- outline: "bg-background hover:bg-accent/50",
- },
- size: {
- default: "",
- sm: "rounded-sm",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "default",
- },
- },
-);
-
-const SelectTrigger = React.forwardRef<
- React.ElementRef<typeof SelectPrimitive.Trigger>,
- React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> &
- VariantProps<typeof selectTriggerVariants> & {
- icon?: React.ReactNode;
- }
->(({ className, children, icon, variant, size, ...props }, ref) => (
- <SelectPrimitive.Trigger
- ref={ref}
- className={cn(selectTriggerVariants({ variant, size }), className)}
- {...props}
- >
- <div className="flex items-center gap-1.5">
- {icon && (
- <span className="text-muted-foreground [&_svg]:size-3.5 shrink-0">
- {icon}
- </span>
- )}
- {children}
- </div>
- <SelectPrimitive.Icon asChild>
- <HugeiconsIcon icon={ArrowDownIcon} className="size-4" />
- </SelectPrimitive.Icon>
- </SelectPrimitive.Trigger>
-));
-SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
-
-const SelectScrollUpButton = React.forwardRef<
- React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
- React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
->(({ className, ...props }, ref) => (
- <SelectPrimitive.ScrollUpButton
- ref={ref}
- className={cn(
- "flex cursor-default items-center justify-center py-1",
- className,
- )}
- {...props}
- >
- <HugeiconsIcon icon={ArrowUpIcon} className="size-4" />
- </SelectPrimitive.ScrollUpButton>
-));
-SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
-
-const SelectScrollDownButton = React.forwardRef<
- React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
- React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
->(({ className, ...props }, ref) => (
- <SelectPrimitive.ScrollDownButton
- ref={ref}
- className={cn(
- "flex cursor-default items-center justify-center py-1",
- className,
- )}
- {...props}
- >
- <HugeiconsIcon icon={ArrowDownIcon} className="size-4" />
- </SelectPrimitive.ScrollDownButton>
-));
-SelectScrollDownButton.displayName =
- SelectPrimitive.ScrollDownButton.displayName;
-
-const SelectContent = React.forwardRef<
- React.ElementRef<typeof SelectPrimitive.Content>,
- React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
->(({ className, children, position = "popper", ...props }, ref) => (
- <SelectPrimitive.Portal>
- <SelectPrimitive.Content
- ref={ref}
- className={cn(
- "bg-popover text-popover-foreground z-50 max-h-(--radix-select-content-available-height) min-w-32 overflow-hidden rounded-md border p-1 shadow-lg",
- className,
- )}
- position={position}
- onCloseAutoFocus={(e) => {
- e.preventDefault();
- e.stopPropagation();
- }}
- {...props}
- >
- <SelectScrollUpButton />
- <SelectPrimitive.Viewport
- className={cn(
- position === "popper" &&
- "h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)",
- )}
- >
- {children}
- </SelectPrimitive.Viewport>
- <SelectScrollDownButton />
- </SelectPrimitive.Content>
- </SelectPrimitive.Portal>
-));
-SelectContent.displayName = SelectPrimitive.Content.displayName;
-
-const SelectLabel = React.forwardRef<
- React.ElementRef<typeof SelectPrimitive.Label>,
- React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
->(({ className, ...props }, ref) => (
- <SelectPrimitive.Label
- ref={ref}
- className={cn(
- "px-2 pb-1 pt-0.5 text-[11px] font-bold uppercase tracking-wider text-muted-foreground",
- className,
- )}
- {...props}
- />
-));
-SelectLabel.displayName = SelectPrimitive.Label.displayName;
-
-const SelectItem = React.forwardRef<
- React.ElementRef<typeof SelectPrimitive.Item>,
- React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
- variant?: VariantProps<typeof selectItemVariants>["variant"];
- }
->(({ className, children, variant = "default", ...props }, ref) => (
- <SelectPrimitive.Item
- ref={ref}
- className={cn(selectItemVariants({ variant }), "pl-6 pr-2", className)}
- {...props}
- >
- <span className="absolute left-1.5 flex size-3.5 items-center justify-center">
- <SelectPrimitive.ItemIndicator>
- <Check className="size-3.5" />
- </SelectPrimitive.ItemIndicator>
- </span>
- <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
- </SelectPrimitive.Item>
-));
-SelectItem.displayName = SelectPrimitive.Item.displayName;
-
-const SelectSeparator = React.forwardRef<
- React.ElementRef<typeof SelectPrimitive.Separator>,
- React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
->(({ className, ...props }, ref) => (
- <SelectPrimitive.Separator
- ref={ref}
- className={cn("bg-border mx-1 my-1 h-px", className)}
- {...props}
- />
-));
-SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
-
-export {
- Select,
- SelectGroup,
- SelectValue,
- SelectTrigger,
- SelectContent,
- SelectLabel,
- SelectItem,
- SelectSeparator,
- SelectScrollUpButton,
- SelectScrollDownButton,
-};
+"use client";
+
+import * as React from "react";
+import { Select as SelectPrimitive } from "radix-ui";
+import { Check } from "lucide-react";
+import { ArrowUpIcon, ArrowDownIcon } from "@hugeicons/core-free-icons";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
+
+function Select({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.Root>) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "select",
+ open,
+ onOpenChange,
+ });
+ return (
+ <SelectPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props} />
+ );
+}
+
+const SelectGroup = SelectPrimitive.Group;
+
+const SelectValue = SelectPrimitive.Value;
+
+const selectItemVariants = cva(
+ "relative flex cursor-pointer select-none items-center gap-1.5 rounded-sm px-2 py-1 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "",
+ destructive:
+ "text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+const selectTriggerVariants = cva(
+ "border-border ring-offset-background placeholder:text-muted-foreground flex h-7 w-auto cursor-pointer items-center justify-between gap-1 rounded-md border px-2.5 text-sm whitespace-nowrap transition-none focus:border-primary focus:ring-0 focus:ring-primary/10 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
+ {
+ variants: {
+ variant: {
+ default: "bg-accent",
+ outline: "bg-background hover:bg-accent/50",
+ },
+ size: {
+ default: "",
+ sm: "rounded-sm",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Trigger>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> &
+ VariantProps<typeof selectTriggerVariants> & {
+ icon?: React.ReactNode;
+ }
+>(({ className, children, icon, variant, size, ...props }, ref) => (
+ <SelectPrimitive.Trigger
+ ref={ref}
+ className={cn(selectTriggerVariants({ variant, size }), className)}
+ {...props}
+ >
+ <div className="flex items-center gap-1.5">
+ {icon && (
+ <span className="text-muted-foreground [&_svg]:size-3.5 shrink-0">
+ {icon}
+ </span>
+ )}
+ {children}
+ </div>
+ <SelectPrimitive.Icon asChild>
+ <HugeiconsIcon icon={ArrowDownIcon} className="size-4" />
+ </SelectPrimitive.Icon>
+ </SelectPrimitive.Trigger>
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
+>(({ className, ...props }, ref) => (
+ <SelectPrimitive.ScrollUpButton
+ ref={ref}
+ className={cn(
+ "flex cursor-default items-center justify-center py-1",
+ className,
+ )}
+ {...props}
+ >
+ <HugeiconsIcon icon={ArrowUpIcon} className="size-4" />
+ </SelectPrimitive.ScrollUpButton>
+));
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
+>(({ className, ...props }, ref) => (
+ <SelectPrimitive.ScrollDownButton
+ ref={ref}
+ className={cn(
+ "flex cursor-default items-center justify-center py-1",
+ className,
+ )}
+ {...props}
+ >
+ <HugeiconsIcon icon={ArrowDownIcon} className="size-4" />
+ </SelectPrimitive.ScrollDownButton>
+));
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName;
+
+const SelectContent = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
+>(({ className, children, position = "popper", ...props }, ref) => (
+ <SelectPrimitive.Portal>
+ <SelectPrimitive.Content
+ ref={ref}
+ className={cn(
+ "bg-popover text-popover-foreground z-50 max-h-(--radix-select-content-available-height) min-w-32 overflow-hidden rounded-md border p-1 shadow-lg",
+ className,
+ )}
+ position={position}
+ onCloseAutoFocus={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ {...props}
+ >
+ <SelectScrollUpButton />
+ <SelectPrimitive.Viewport
+ className={cn(
+ position === "popper" &&
+ "h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)",
+ )}
+ >
+ {children}
+ </SelectPrimitive.Viewport>
+ <SelectScrollDownButton />
+ </SelectPrimitive.Content>
+ </SelectPrimitive.Portal>
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Label>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
+>(({ className, ...props }, ref) => (
+ <SelectPrimitive.Label
+ ref={ref}
+ className={cn(
+ "px-2 pb-1 pt-0.5 text-[11px] font-bold uppercase tracking-wider text-muted-foreground",
+ className,
+ )}
+ {...props}
+ />
+));
+SelectLabel.displayName = SelectPrimitive.Label.displayName;
+
+const SelectItem = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Item>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
+ variant?: VariantProps<typeof selectItemVariants>["variant"];
+ }
+>(({ className, children, variant = "default", ...props }, ref) => (
+ <SelectPrimitive.Item
+ ref={ref}
+ className={cn(selectItemVariants({ variant }), "pl-6 pr-2", className)}
+ {...props}
+ >
+ <span className="absolute left-1.5 flex size-3.5 items-center justify-center">
+ <SelectPrimitive.ItemIndicator>
+ <Check className="size-3.5" />
+ </SelectPrimitive.ItemIndicator>
+ </span>
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
+ </SelectPrimitive.Item>
+));
+SelectItem.displayName = SelectPrimitive.Item.displayName;
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Separator>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
+>(({ className, ...props }, ref) => (
+ <SelectPrimitive.Separator
+ ref={ref}
+ className={cn("bg-border mx-1 my-1 h-px", className)}
+ {...props}
+ />
+));
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+};
diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx
index d56cd0ac..675e6b96 100644
--- a/apps/web/src/components/ui/sheet.tsx
+++ b/apps/web/src/components/ui/sheet.tsx
@@ -1,157 +1,157 @@
-"use client";
-
-import * as React from "react";
-import { Dialog as SheetPrimitive } from "radix-ui";
-import { cva, type VariantProps } from "class-variance-authority";
-import { X } from "lucide-react";
-import { cn } from "@/utils/ui";
-import { useOverlayOpenChange } from "./use-overlay-open-change";
-
-function Sheet({
- open,
- onOpenChange,
- ...props
-}: React.ComponentProps<typeof SheetPrimitive.Root>) {
- const handleOpenChange = useOverlayOpenChange({
- source: "sheet",
- open,
- onOpenChange,
- });
- return (
- <SheetPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props} />
- );
-}
-
-const SheetTrigger = SheetPrimitive.Trigger;
-
-const SheetClose = SheetPrimitive.Close;
-
-const SheetPortal = SheetPrimitive.Portal;
-
-const SheetOverlay = React.forwardRef<
- React.ElementRef<typeof SheetPrimitive.Overlay>,
- React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
->(({ className, ...props }, ref) => (
- <SheetPrimitive.Overlay
- className={cn(
- "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-200 bg-black/50",
- className,
- )}
- {...props}
- ref={ref}
- />
-));
-SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
-
-const sheetVariants = cva(
- "fixed z-250 gap-4 bg-background p-6 shadow-lg transition ease data-[state=closed]:duration-250 data-[state=open]:duration-250 data-[state=open]:animate-in data-[state=closed]:animate-out",
- {
- variants: {
- side: {
- top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
- bottom:
- "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
- left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
- right:
- "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
- },
- },
- defaultVariants: {
- side: "right",
- },
- },
-);
-
-interface SheetContentProps
- extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
- VariantProps<typeof sheetVariants> {}
-
-const SheetContent = React.forwardRef<
- React.ElementRef<typeof SheetPrimitive.Content>,
- SheetContentProps
->(({ side = "right", className, children, ...props }, ref) => (
- <SheetPortal>
- <SheetOverlay />
- <SheetPrimitive.Content
- ref={ref}
- className={cn(sheetVariants({ side }), className)}
- onOpenAutoFocus={(e) => {
- e.preventDefault();
- e.stopPropagation();
- }}
- {...props}
- >
- <SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 cursor-pointer rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
- <X className="size-5" />
- <span className="sr-only">Close</span>
- </SheetPrimitive.Close>
- {children}
- </SheetPrimitive.Content>
- </SheetPortal>
-));
-SheetContent.displayName = SheetPrimitive.Content.displayName;
-
-const SheetHeader = ({
- className,
- ...props
-}: React.HTMLAttributes<HTMLDivElement>) => (
- <div
- className={cn(
- "flex flex-col space-y-2 text-center sm:text-left",
- className,
- )}
- {...props}
- />
-);
-SheetHeader.displayName = "SheetHeader";
-
-const SheetFooter = ({
- className,
- ...props
-}: React.HTMLAttributes<HTMLDivElement>) => (
- <div
- className={cn(
- "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-4",
- className,
- )}
- {...props}
- />
-);
-SheetFooter.displayName = "SheetFooter";
-
-const SheetTitle = React.forwardRef<
- React.ElementRef<typeof SheetPrimitive.Title>,
- React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
->(({ className, ...props }, ref) => (
- <SheetPrimitive.Title
- ref={ref}
- className={cn("text-foreground text-lg font-semibold", className)}
- {...props}
- />
-));
-SheetTitle.displayName = SheetPrimitive.Title.displayName;
-
-const SheetDescription = React.forwardRef<
- React.ElementRef<typeof SheetPrimitive.Description>,
- React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
->(({ className, ...props }, ref) => (
- <SheetPrimitive.Description
- ref={ref}
- className={cn("text-muted-foreground text-sm", className)}
- {...props}
- />
-));
-SheetDescription.displayName = SheetPrimitive.Description.displayName;
-
-export {
- Sheet,
- SheetPortal,
- SheetOverlay,
- SheetTrigger,
- SheetClose,
- SheetContent,
- SheetHeader,
- SheetFooter,
- SheetTitle,
- SheetDescription,
-};
+"use client";
+
+import * as React from "react";
+import { Dialog as SheetPrimitive } from "radix-ui";
+import { cva, type VariantProps } from "class-variance-authority";
+import { X } from "lucide-react";
+import { cn } from "@/utils/ui";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
+
+function Sheet({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps<typeof SheetPrimitive.Root>) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "sheet",
+ open,
+ onOpenChange,
+ });
+ return (
+ <SheetPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props} />
+ );
+}
+
+const SheetTrigger = SheetPrimitive.Trigger;
+
+const SheetClose = SheetPrimitive.Close;
+
+const SheetPortal = SheetPrimitive.Portal;
+
+const SheetOverlay = React.forwardRef<
+ React.ElementRef<typeof SheetPrimitive.Overlay>,
+ React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
+>(({ className, ...props }, ref) => (
+ <SheetPrimitive.Overlay
+ className={cn(
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-200 bg-black/50",
+ className,
+ )}
+ {...props}
+ ref={ref}
+ />
+));
+SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
+
+const sheetVariants = cva(
+ "fixed z-250 gap-4 bg-background p-6 shadow-lg transition ease data-[state=closed]:duration-250 data-[state=open]:duration-250 data-[state=open]:animate-in data-[state=closed]:animate-out",
+ {
+ variants: {
+ side: {
+ top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
+ bottom:
+ "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
+ left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
+ right:
+ "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
+ },
+ },
+ defaultVariants: {
+ side: "right",
+ },
+ },
+);
+
+interface SheetContentProps
+ extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
+ VariantProps<typeof sheetVariants> {}
+
+const SheetContent = React.forwardRef<
+ React.ElementRef<typeof SheetPrimitive.Content>,
+ SheetContentProps
+>(({ side = "right", className, children, ...props }, ref) => (
+ <SheetPortal>
+ <SheetOverlay />
+ <SheetPrimitive.Content
+ ref={ref}
+ className={cn(sheetVariants({ side }), className)}
+ onOpenAutoFocus={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ {...props}
+ >
+ <SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 cursor-pointer rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
+ <X className="size-5" />
+ <span className="sr-only">Close</span>
+ </SheetPrimitive.Close>
+ {children}
+ </SheetPrimitive.Content>
+ </SheetPortal>
+));
+SheetContent.displayName = SheetPrimitive.Content.displayName;
+
+const SheetHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={cn(
+ "flex flex-col space-y-2 text-center sm:text-left",
+ className,
+ )}
+ {...props}
+ />
+);
+SheetHeader.displayName = "SheetHeader";
+
+const SheetFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={cn(
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-4",
+ className,
+ )}
+ {...props}
+ />
+);
+SheetFooter.displayName = "SheetFooter";
+
+const SheetTitle = React.forwardRef<
+ React.ElementRef<typeof SheetPrimitive.Title>,
+ React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
+>(({ className, ...props }, ref) => (
+ <SheetPrimitive.Title
+ ref={ref}
+ className={cn("text-foreground text-lg font-semibold", className)}
+ {...props}
+ />
+));
+SheetTitle.displayName = SheetPrimitive.Title.displayName;
+
+const SheetDescription = React.forwardRef<
+ React.ElementRef<typeof SheetPrimitive.Description>,
+ React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
+>(({ className, ...props }, ref) => (
+ <SheetPrimitive.Description
+ ref={ref}
+ className={cn("text-muted-foreground text-sm", className)}
+ {...props}
+ />
+));
+SheetDescription.displayName = SheetPrimitive.Description.displayName;
+
+export {
+ Sheet,
+ SheetPortal,
+ SheetOverlay,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+};
diff --git a/apps/web/src/components/ui/slider.tsx b/apps/web/src/components/ui/slider.tsx
index 700be644..aa14dfbd 100644
--- a/apps/web/src/components/ui/slider.tsx
+++ b/apps/web/src/components/ui/slider.tsx
@@ -1,30 +1,30 @@
-"use client";
-
-import * as React from "react";
-import { Slider as SliderPrimitive } from "radix-ui";
-
-import { cn } from "@/utils/ui";
-
-const Slider = React.forwardRef<
- React.ElementRef<typeof SliderPrimitive.Root>,
- React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
- className?: string;
- }
->(({ className, ...props }, ref) => (
- <SliderPrimitive.Root
- ref={ref}
- className={cn(
- "relative flex w-full touch-none items-center select-none",
- className,
- )}
- {...props}
- >
- <SliderPrimitive.Track className="bg-accent relative h-1.5 w-full grow overflow-hidden rounded-full">
- <SliderPrimitive.Range className="bg-primary absolute h-full" />
- </SliderPrimitive.Track>
- <SliderPrimitive.Thumb className="border-primary/50 bg-background focus-visible:ring-ring block size-4 rounded-full border shadow-sm focus-visible:ring-1 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50" />
- </SliderPrimitive.Root>
-));
-Slider.displayName = SliderPrimitive.Root.displayName;
-
-export { Slider };
+"use client";
+
+import * as React from "react";
+import { Slider as SliderPrimitive } from "radix-ui";
+
+import { cn } from "@/utils/ui";
+
+const Slider = React.forwardRef<
+ React.ElementRef<typeof SliderPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
+ className?: string;
+ }
+>(({ className, ...props }, ref) => (
+ <SliderPrimitive.Root
+ ref={ref}
+ className={cn(
+ "relative flex w-full touch-none items-center select-none",
+ className,
+ )}
+ {...props}
+ >
+ <SliderPrimitive.Track className="bg-accent relative h-1.5 w-full grow overflow-hidden rounded-full">
+ <SliderPrimitive.Range className="bg-primary absolute h-full" />
+ </SliderPrimitive.Track>
+ <SliderPrimitive.Thumb className="border-primary/50 bg-background focus-visible:ring-ring block size-4 rounded-full border shadow-sm focus-visible:ring-1 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50" />
+ </SliderPrimitive.Root>
+));
+Slider.displayName = SliderPrimitive.Root.displayName;
+
+export { Slider };
diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx
index f0c8197a..980dcd42 100644
--- a/apps/web/src/components/ui/sonner.tsx
+++ b/apps/web/src/components/ui/sonner.tsx
@@ -1,35 +1,35 @@
-"use client";
-
-import { useTheme } from "next-themes";
-import { Toaster as Sonner } from "sonner";
-
-type ToasterProps = React.ComponentProps<typeof Sonner>;
-
-const Toaster = ({ ...props }: ToasterProps) => {
- const { theme = "system" } = useTheme();
-
- return (
- <Sonner
- theme={theme as ToasterProps["theme"]}
- className="toaster group"
- position="bottom-right"
- offset={20}
- toastOptions={{
- classNames: {
- toast:
- "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
- description: "group-[.toast]:text-muted-foreground",
- actionButton:
- "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
- cancelButton:
- "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
- },
- }}
- expand={false}
- richColors
- {...props}
- />
- );
-};
-
-export { Toaster };
+"use client";
+
+import { useTheme } from "next-themes";
+import { Toaster as Sonner } from "sonner";
+
+type ToasterProps = React.ComponentProps<typeof Sonner>;
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme();
+
+ return (
+ <Sonner
+ theme={theme as ToasterProps["theme"]}
+ className="toaster group"
+ position="bottom-right"
+ offset={20}
+ toastOptions={{
+ classNames: {
+ toast:
+ "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
+ description: "group-[.toast]:text-muted-foreground",
+ actionButton:
+ "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
+ cancelButton:
+ "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
+ },
+ }}
+ expand={false}
+ richColors
+ {...props}
+ />
+ );
+};
+
+export { Toaster };
diff --git a/apps/web/src/components/ui/spinner.tsx b/apps/web/src/components/ui/spinner.tsx
index 5546d539..3722a218 100644
--- a/apps/web/src/components/ui/spinner.tsx
+++ b/apps/web/src/components/ui/spinner.tsx
@@ -1,17 +1,17 @@
-import { HugeiconsIcon, type HugeiconsIconProps } from "@hugeicons/react";
-import { Loading03Icon } from "@hugeicons/core-free-icons";
-import { cn } from "@/utils/ui";
-
-function Spinner({ className, ...props }: Omit<HugeiconsIconProps, "icon">) {
- return (
- <HugeiconsIcon
- icon={Loading03Icon}
- role="status"
- aria-label="Loading"
- className={cn("size-4 animate-spin", className)}
- {...props}
- />
- );
-}
-
-export { Spinner };
+import { HugeiconsIcon, type HugeiconsIconProps } from "@hugeicons/react";
+import { Loading03Icon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+
+function Spinner({ className, ...props }: Omit<HugeiconsIconProps, "icon">) {
+ return (
+ <HugeiconsIcon
+ icon={Loading03Icon}
+ role="status"
+ aria-label="Loading"
+ className={cn("size-4 animate-spin", className)}
+ {...props}
+ />
+ );
+}
+
+export { Spinner };
diff --git a/apps/web/src/components/ui/split-button.tsx b/apps/web/src/components/ui/split-button.tsx
index dfc4cdfe..aa8b331a 100644
--- a/apps/web/src/components/ui/split-button.tsx
+++ b/apps/web/src/components/ui/split-button.tsx
@@ -1,90 +1,90 @@
-import { Button, type ButtonProps } from "@/components/ui/button";
-import { Separator } from "@/components/ui/separator";
-import { type ReactNode, forwardRef } from "react";
-import { cn } from "@/utils/ui";
-
-interface SplitButtonProps {
- children: ReactNode;
- className?: string;
-}
-
-interface SplitButtonSideProps extends Omit<ButtonProps, "variant" | "size"> {
- children: ReactNode;
-}
-
-const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>(
- ({ children, className, ...props }, ref) => {
- return (
- <div
- ref={ref}
- className={cn(
- "border-input bg-accent inline-flex h-7 overflow-hidden rounded-lg border",
- className,
- )}
- {...props}
- >
- {children}
- </div>
- );
- },
-);
-SplitButton.displayName = "SplitButton";
-
-const SplitButtonSide = forwardRef<
- HTMLButtonElement,
- SplitButtonSideProps & { paddingClass: string }
->(({ children, className, paddingClass, onClick, ...props }, ref) => {
- return (
- <Button
- ref={ref}
- variant="text"
- className={cn(
- "bg-accent disabled:text-muted-foreground h-full gap-0 rounded-none border-0 font-normal !opacity-100",
- onClick
- ? "hover:bg-foreground/10 cursor-pointer hover:opacity-100"
- : "cursor-default select-text",
- paddingClass,
- className,
- )}
- onClick={onClick}
- {...props}
- >
- {typeof children === "string" ? (
- <span className="cursor-text font-normal">{children}</span>
- ) : (
- children
- )}
- </Button>
- );
-});
-SplitButtonSide.displayName = "SplitButtonSide";
-
-const SplitButtonLeft = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
- ({ ...props }, ref) => {
- return <SplitButtonSide ref={ref} paddingClass="pl-3 pr-2" {...props} />;
- },
-);
-SplitButtonLeft.displayName = "SplitButtonLeft";
-
-const SplitButtonRight = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
- ({ ...props }, ref) => {
- return <SplitButtonSide ref={ref} paddingClass="pl-2 pr-3" {...props} />;
- },
-);
-SplitButtonRight.displayName = "SplitButtonRight";
-
-const SplitButtonSeparator = forwardRef<HTMLDivElement, { className?: string }>(
- ({ className, ...props }, ref) => {
- return (
- <Separator
- ref={ref}
- orientation="vertical"
- className={cn("bg-foreground/15 h-full", className)}
- {...props}
- />
- );
- },
-);
-SplitButtonSeparator.displayName = "SplitButtonSeparator";
-
-export { SplitButton, SplitButtonLeft, SplitButtonRight, SplitButtonSeparator };
+import { Button, type ButtonProps } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import { type ReactNode, forwardRef } from "react";
+import { cn } from "@/utils/ui";
+
+interface SplitButtonProps {
+ children: ReactNode;
+ className?: string;
+}
+
+interface SplitButtonSideProps extends Omit<ButtonProps, "variant" | "size"> {
+ children: ReactNode;
+}
+
+const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>(
+ ({ children, className, ...props }, ref) => {
+ return (
+ <div
+ ref={ref}
+ className={cn(
+ "border-input bg-accent inline-flex h-7 overflow-hidden rounded-lg border",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ </div>
+ );
+ },
+);
+SplitButton.displayName = "SplitButton";
+
+const SplitButtonSide = forwardRef<
+ HTMLButtonElement,
+ SplitButtonSideProps & { paddingClass: string }
+>(({ children, className, paddingClass, onClick, ...props }, ref) => {
+ return (
+ <Button
+ ref={ref}
+ variant="text"
+ className={cn(
+ "bg-accent disabled:text-muted-foreground h-full gap-0 rounded-none border-0 font-normal !opacity-100",
+ onClick
+ ? "hover:bg-foreground/10 cursor-pointer hover:opacity-100"
+ : "cursor-default select-text",
+ paddingClass,
+ className,
+ )}
+ onClick={onClick}
+ {...props}
+ >
+ {typeof children === "string" ? (
+ <span className="cursor-text font-normal">{children}</span>
+ ) : (
+ children
+ )}
+ </Button>
+ );
+});
+SplitButtonSide.displayName = "SplitButtonSide";
+
+const SplitButtonLeft = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
+ ({ ...props }, ref) => {
+ return <SplitButtonSide ref={ref} paddingClass="pl-3 pr-2" {...props} />;
+ },
+);
+SplitButtonLeft.displayName = "SplitButtonLeft";
+
+const SplitButtonRight = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
+ ({ ...props }, ref) => {
+ return <SplitButtonSide ref={ref} paddingClass="pl-2 pr-3" {...props} />;
+ },
+);
+SplitButtonRight.displayName = "SplitButtonRight";
+
+const SplitButtonSeparator = forwardRef<HTMLDivElement, { className?: string }>(
+ ({ className, ...props }, ref) => {
+ return (
+ <Separator
+ ref={ref}
+ orientation="vertical"
+ className={cn("bg-foreground/15 h-full", className)}
+ {...props}
+ />
+ );
+ },
+);
+SplitButtonSeparator.displayName = "SplitButtonSeparator";
+
+export { SplitButton, SplitButtonLeft, SplitButtonRight, SplitButtonSeparator };
diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx
index c5cc02d7..e436e876 100644
--- a/apps/web/src/components/ui/tabs.tsx
+++ b/apps/web/src/components/ui/tabs.tsx
@@ -1,85 +1,85 @@
-"use client";
-
-import * as React from "react";
-import { Tabs as TabsPrimitive } from "radix-ui";
-
-import { cn } from "@/utils/ui";
-
-type TabsVariant = "default" | "underline";
-
-const TabsVariantContext = React.createContext<TabsVariant>("default");
-
-const Tabs = React.forwardRef<
- React.ElementRef<typeof TabsPrimitive.Root>,
- React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root> & {
- variant?: TabsVariant;
- }
->(({ variant = "default", ...props }, ref) => (
- <TabsVariantContext.Provider value={variant}>
- <TabsPrimitive.Root ref={ref} {...props} />
- </TabsVariantContext.Provider>
-));
-Tabs.displayName = TabsPrimitive.Root.displayName;
-
-const TabsList = React.forwardRef<
- React.ElementRef<typeof TabsPrimitive.List>,
- React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
->(({ className, ...props }, ref) => {
- const variant = React.useContext(TabsVariantContext);
- return (
- <TabsPrimitive.List
- ref={ref}
- className={cn(
- "text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0",
- variant === "default" && "rounded-lg",
- variant === "underline" && "border-b border-border w-full gap-0 px-2",
- className,
- )}
- {...props}
- />
- );
-});
-TabsList.displayName = TabsPrimitive.List.displayName;
-
-const TabsTrigger = React.forwardRef<
- React.ElementRef<typeof TabsPrimitive.Trigger>,
- React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
->(({ className, ...props }, ref) => {
- const variant = React.useContext(TabsVariantContext);
- return (
- <TabsPrimitive.Trigger
- ref={ref}
- className={cn(
- "ring-offset-background focus-visible:ring-ring inline-flex cursor-pointer items-center justify-center text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 border border-transparent",
- variant === "default" &&
- "data-[state=active]:bg-secondary data-[state=active]:border-secondary-border data-[state=active]:text-secondary-foreground rounded-md px-2.5 h-6.5",
- variant === "underline" &&
- "text-muted-foreground data-[state=active]:text-primary border-b-2 border-transparent data-[state=active]:border-primary rounded-none px-3 py-2 -mb-px",
- className,
- )}
- {...props}
- />
- );
-});
-TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
-
-const TabsContent = React.forwardRef<
- React.ElementRef<typeof TabsPrimitive.Content>,
- React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
->(({ className, ...props }, ref) => {
- const variant = React.useContext(TabsVariantContext);
- return (
- <TabsPrimitive.Content
- ref={ref}
- className={cn(
- "ring-offset-background focus-visible:ring-ring mt-4 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden",
- variant === "underline" && "px-4",
- className,
- )}
- {...props}
- />
- );
-});
-TabsContent.displayName = TabsPrimitive.Content.displayName;
-
-export { Tabs, TabsList, TabsTrigger, TabsContent };
+"use client";
+
+import * as React from "react";
+import { Tabs as TabsPrimitive } from "radix-ui";
+
+import { cn } from "@/utils/ui";
+
+type TabsVariant = "default" | "underline";
+
+const TabsVariantContext = React.createContext<TabsVariant>("default");
+
+const Tabs = React.forwardRef<
+ React.ElementRef<typeof TabsPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root> & {
+ variant?: TabsVariant;
+ }
+>(({ variant = "default", ...props }, ref) => (
+ <TabsVariantContext.Provider value={variant}>
+ <TabsPrimitive.Root ref={ref} {...props} />
+ </TabsVariantContext.Provider>
+));
+Tabs.displayName = TabsPrimitive.Root.displayName;
+
+const TabsList = React.forwardRef<
+ React.ElementRef<typeof TabsPrimitive.List>,
+ React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
+>(({ className, ...props }, ref) => {
+ const variant = React.useContext(TabsVariantContext);
+ return (
+ <TabsPrimitive.List
+ ref={ref}
+ className={cn(
+ "text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0",
+ variant === "default" && "rounded-lg",
+ variant === "underline" && "border-b border-border w-full gap-0 px-2",
+ className,
+ )}
+ {...props}
+ />
+ );
+});
+TabsList.displayName = TabsPrimitive.List.displayName;
+
+const TabsTrigger = React.forwardRef<
+ React.ElementRef<typeof TabsPrimitive.Trigger>,
+ React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
+>(({ className, ...props }, ref) => {
+ const variant = React.useContext(TabsVariantContext);
+ return (
+ <TabsPrimitive.Trigger
+ ref={ref}
+ className={cn(
+ "ring-offset-background focus-visible:ring-ring inline-flex cursor-pointer items-center justify-center text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 border border-transparent",
+ variant === "default" &&
+ "data-[state=active]:bg-secondary data-[state=active]:border-secondary-border data-[state=active]:text-secondary-foreground rounded-md px-2.5 h-6.5",
+ variant === "underline" &&
+ "text-muted-foreground data-[state=active]:text-primary border-b-2 border-transparent data-[state=active]:border-primary rounded-none px-3 py-2 -mb-px",
+ className,
+ )}
+ {...props}
+ />
+ );
+});
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
+
+const TabsContent = React.forwardRef<
+ React.ElementRef<typeof TabsPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
+>(({ className, ...props }, ref) => {
+ const variant = React.useContext(TabsVariantContext);
+ return (
+ <TabsPrimitive.Content
+ ref={ref}
+ className={cn(
+ "ring-offset-background focus-visible:ring-ring mt-4 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden",
+ variant === "underline" && "px-4",
+ className,
+ )}
+ {...props}
+ />
+ );
+});
+TabsContent.displayName = TabsPrimitive.Content.displayName;
+
+export { Tabs, TabsList, TabsTrigger, TabsContent };
diff --git a/apps/web/src/components/ui/textarea.tsx b/apps/web/src/components/ui/textarea.tsx
index 2ae59697..bc3056e8 100644
--- a/apps/web/src/components/ui/textarea.tsx
+++ b/apps/web/src/components/ui/textarea.tsx
@@ -1,24 +1,24 @@
-import * as React from "react";
-
-import { cn } from "@/utils/ui";
-
-const Textarea = React.forwardRef<
- HTMLTextAreaElement,
- React.ComponentProps<"textarea">
->(({ className, ...props }, ref) => {
- return (
- <textarea
- className={cn(
- "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input bg-accent flex min-h-[60px] w-full rounded-md border px-3 py-2 resize-none text-base shadow-xs outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
- "focus-visible:border-primary focus-visible:ring-0 focus-visible:ring-primary/10",
- "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
- className,
- )}
- ref={ref}
- {...props}
- />
- );
-});
-Textarea.displayName = "Textarea";
-
-export { Textarea };
+import * as React from "react";
+
+import { cn } from "@/utils/ui";
+
+const Textarea = React.forwardRef<
+ HTMLTextAreaElement,
+ React.ComponentProps<"textarea">
+>(({ className, ...props }, ref) => {
+ return (
+ <textarea
+ className={cn(
+ "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input bg-accent flex min-h-[60px] w-full rounded-md border px-3 py-2 resize-none text-base shadow-xs outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+ "focus-visible:border-primary focus-visible:ring-0 focus-visible:ring-primary/10",
+ "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ className,
+ )}
+ ref={ref}
+ {...props}
+ />
+ );
+});
+Textarea.displayName = "Textarea";
+
+export { Textarea };
diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx
index 2e7b53d4..b6902402 100644
--- a/apps/web/src/components/ui/tooltip.tsx
+++ b/apps/web/src/components/ui/tooltip.tsx
@@ -1,76 +1,76 @@
-import { cva, type VariantProps } from "class-variance-authority";
-import { Tooltip as TooltipPrimitive } from "radix-ui";
-import * as React from "react";
-
-import { cn } from "@/utils/ui";
-
-const TooltipProvider = TooltipPrimitive.Provider;
-
-const Tooltip = TooltipPrimitive.Root;
-
-const TooltipTrigger = TooltipPrimitive.Trigger;
-
-const tooltipVariants = cva(
- "z-50 overflow-visible rounded-sm text-sm shadow-md",
- {
- variants: {
- variant: {
- default: "bg-popover text-popover-foreground border px-3 py-1.5",
- destructive:
- "bg-destructive/10 text-destructive dark:bg-destructive/20 border-destructive [border-width:0.5px]",
- outline: "border-border",
- important:
- "bg-amber-100/90 text-amber-900 dark:bg-amber-900/20 dark:text-amber-300 border-amber-900 [border-width:0.5px]",
- promotions:
- "bg-red-100/90 text-redb-900 dark:bg-red-900/20 dark:text-red-300 border-red-900 [border-width:0.5px]",
- personal:
- "bg-green-100/90 text-green-900 dark:bg-green-900/20 dark:text-green-300 border-green-900 [border-width:0.5px]",
- updates:
- "bg-purple-100/90 text-purple-900 dark:bg-purple-900/20 dark:text-purple-300 border-purple-900 [border-width:0.5px]",
- forums:
- "bg-blue-100/90 text-blue-900 dark:bg-blue-900/20 dark:text-blue-300 border-blue-900 [border-width:0.5px]",
- sidebar: "bg-white dark:bg-[#413F3E] p-2.5 flex flex-col gap-2",
- },
- },
- defaultVariants: {
- variant: "default",
- },
- },
-);
-
-interface TooltipContentProps
- extends React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>,
- VariantProps<typeof tooltipVariants> {}
-
-const TooltipContent = React.forwardRef<
- React.ElementRef<typeof TooltipPrimitive.Content>,
- TooltipContentProps
->(({ className, sideOffset = 4, variant, ...props }, ref) => (
- <TooltipPrimitive.Content
- ref={ref}
- sideOffset={sideOffset}
- className={cn(tooltipVariants({ variant }), className)}
- {...props}
- >
- {variant === "sidebar" && (
- <svg
- width="6"
- height="10"
- viewBox="0 0 6 10"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- className="absolute top-1/2 left-[-6px] -translate-y-1/2"
- aria-hidden="true"
- >
- <path
- d="M6 0L0 5L6 10V0Z"
- className="fill-white/80 dark:fill-[#413F3E]"
- />
- </svg>
- )}
- {props.children}
- </TooltipPrimitive.Content>
-));
-TooltipContent.displayName = TooltipPrimitive.Content.displayName;
-
-export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+import { cva, type VariantProps } from "class-variance-authority";
+import { Tooltip as TooltipPrimitive } from "radix-ui";
+import * as React from "react";
+
+import { cn } from "@/utils/ui";
+
+const TooltipProvider = TooltipPrimitive.Provider;
+
+const Tooltip = TooltipPrimitive.Root;
+
+const TooltipTrigger = TooltipPrimitive.Trigger;
+
+const tooltipVariants = cva(
+ "z-50 overflow-visible rounded-sm text-sm shadow-md",
+ {
+ variants: {
+ variant: {
+ default: "bg-popover text-popover-foreground border px-3 py-1.5",
+ destructive:
+ "bg-destructive/10 text-destructive dark:bg-destructive/20 border-destructive [border-width:0.5px]",
+ outline: "border-border",
+ important:
+ "bg-amber-100/90 text-amber-900 dark:bg-amber-900/20 dark:text-amber-300 border-amber-900 [border-width:0.5px]",
+ promotions:
+ "bg-red-100/90 text-redb-900 dark:bg-red-900/20 dark:text-red-300 border-red-900 [border-width:0.5px]",
+ personal:
+ "bg-green-100/90 text-green-900 dark:bg-green-900/20 dark:text-green-300 border-green-900 [border-width:0.5px]",
+ updates:
+ "bg-purple-100/90 text-purple-900 dark:bg-purple-900/20 dark:text-purple-300 border-purple-900 [border-width:0.5px]",
+ forums:
+ "bg-blue-100/90 text-blue-900 dark:bg-blue-900/20 dark:text-blue-300 border-blue-900 [border-width:0.5px]",
+ sidebar: "bg-white dark:bg-[#413F3E] p-2.5 flex flex-col gap-2",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+interface TooltipContentProps
+ extends React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>,
+ VariantProps<typeof tooltipVariants> {}
+
+const TooltipContent = React.forwardRef<
+ React.ElementRef<typeof TooltipPrimitive.Content>,
+ TooltipContentProps
+>(({ className, sideOffset = 4, variant, ...props }, ref) => (
+ <TooltipPrimitive.Content
+ ref={ref}
+ sideOffset={sideOffset}
+ className={cn(tooltipVariants({ variant }), className)}
+ {...props}
+ >
+ {variant === "sidebar" && (
+ <svg
+ width="6"
+ height="10"
+ viewBox="0 0 6 10"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ className="absolute top-1/2 left-[-6px] -translate-y-1/2"
+ aria-hidden="true"
+ >
+ <path
+ d="M6 0L0 5L6 10V0Z"
+ className="fill-white/80 dark:fill-[#413F3E]"
+ />
+ </svg>
+ )}
+ {props.children}
+ </TooltipPrimitive.Content>
+));
+TooltipContent.displayName = TooltipPrimitive.Content.displayName;
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
diff --git a/apps/web/src/components/ui/use-overlay-open-change.ts b/apps/web/src/components/ui/use-overlay-open-change.ts
index 165efc79..c5ef5331 100644
--- a/apps/web/src/components/ui/use-overlay-open-change.ts
+++ b/apps/web/src/components/ui/use-overlay-open-change.ts
@@ -1,78 +1,78 @@
-import { useCallback, useEffect, useId, useRef } from "react";
-import { useKeybindingsStore } from "@/stores/keybindings-store";
-
-export function useOverlayOpenChange({
- source,
- open,
- onOpenChange,
-}: {
- source: string;
- open?: boolean;
- onOpenChange?: (open: boolean) => void;
-}) {
- const { openOverlay, closeOverlay } = useKeybindingsStore();
- const isTrackedRef = useRef(false);
- const isControlled = typeof open === "boolean";
- const overlayId = useId();
-
- useEffect(() => {
- if (!isControlled) return;
-
- if (open && !isTrackedRef.current) {
- openOverlay(overlayId, source);
- isTrackedRef.current = true;
- return;
- }
-
- if (!open && isTrackedRef.current) {
- closeOverlay(overlayId, source);
- isTrackedRef.current = false;
- }
- }, [closeOverlay, isControlled, open, openOverlay, overlayId, source]);
-
- useEffect(() => {
- return () => {
- if (!isTrackedRef.current) return;
- // #region agent log
- fetch(
- "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7",
- {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- "X-Debug-Session-Id": "3997d9",
- },
- body: JSON.stringify({
- sessionId: "3997d9",
- runId: "post-fix",
- hypothesisId: "H2",
- location: "use-overlay-open-change.ts:cleanup",
- message: "Overlay closed during unmount cleanup",
- data: { source, overlayId },
- timestamp: Date.now(),
- }),
- },
- ).catch(() => {});
- // #endregion
- closeOverlay(overlayId, source);
- isTrackedRef.current = false;
- };
- }, [closeOverlay, overlayId, source]);
-
- return useCallback(
- (nextOpen: boolean) => {
- if (!isControlled) {
- if (nextOpen && !isTrackedRef.current) {
- openOverlay(overlayId, source);
- isTrackedRef.current = true;
- } else if (!nextOpen && isTrackedRef.current) {
- closeOverlay(overlayId, source);
- isTrackedRef.current = false;
- }
- }
-
- onOpenChange?.(nextOpen);
- },
- [closeOverlay, isControlled, onOpenChange, openOverlay, overlayId, source],
- );
-}
+import { useCallback, useEffect, useId, useRef } from "react";
+import { useKeybindingsStore } from "@/stores/keybindings-store";
+
+export function useOverlayOpenChange({
+ source,
+ open,
+ onOpenChange,
+}: {
+ source: string;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+}) {
+ const { openOverlay, closeOverlay } = useKeybindingsStore();
+ const isTrackedRef = useRef(false);
+ const isControlled = typeof open === "boolean";
+ const overlayId = useId();
+
+ useEffect(() => {
+ if (!isControlled) return;
+
+ if (open && !isTrackedRef.current) {
+ openOverlay(overlayId, source);
+ isTrackedRef.current = true;
+ return;
+ }
+
+ if (!open && isTrackedRef.current) {
+ closeOverlay(overlayId, source);
+ isTrackedRef.current = false;
+ }
+ }, [closeOverlay, isControlled, open, openOverlay, overlayId, source]);
+
+ useEffect(() => {
+ return () => {
+ if (!isTrackedRef.current) return;
+ // #region agent log
+ fetch(
+ "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Debug-Session-Id": "3997d9",
+ },
+ body: JSON.stringify({
+ sessionId: "3997d9",
+ runId: "post-fix",
+ hypothesisId: "H2",
+ location: "use-overlay-open-change.ts:cleanup",
+ message: "Overlay closed during unmount cleanup",
+ data: { source, overlayId },
+ timestamp: Date.now(),
+ }),
+ },
+ ).catch(() => {});
+ // #endregion
+ closeOverlay(overlayId, source);
+ isTrackedRef.current = false;
+ };
+ }, [closeOverlay, overlayId, source]);
+
+ return useCallback(
+ (nextOpen: boolean) => {
+ if (!isControlled) {
+ if (nextOpen && !isTrackedRef.current) {
+ openOverlay(overlayId, source);
+ isTrackedRef.current = true;
+ } else if (!nextOpen && isTrackedRef.current) {
+ closeOverlay(overlayId, source);
+ isTrackedRef.current = false;
+ }
+ }
+
+ onOpenChange?.(nextOpen);
+ },
+ [closeOverlay, isControlled, onOpenChange, openOverlay, overlayId, source],
+ );
+}