diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-27 00:55:15 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-29 15:56:48 +0200 |
| commit | 4d127a038bf768eded68aae3cfa5c1b12241bdac (patch) | |
| tree | 16bbb801caf6770546cff4fdb37b6408ec9ead98 /apps/web/src/components/ui | |
| parent | 82817cb50cb0487350573d6129130aec705f924b (diff) | |
| download | opencut-4d127a038bf768eded68aae3cfa5c1b12241bdac.tar.gz | |
chore: normalize line endings
Diffstat (limited to 'apps/web/src/components/ui')
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],
+ );
+}
|
