mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-25 09:24:54 +10:00
initial commit of v5
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import {
|
||||
Button as ButtonPrimitive,
|
||||
type ButtonProps as ButtonPrimitiveProps,
|
||||
} from "@/components/animate-ui/primitives/buttons/button";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-[box-shadow,color,background-color,border-color,outline-color,text-decoration-color,fill,stroke] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
accent: "bg-accent text-accent-foreground shadow-xs hover:bg-accent/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8 rounded-md",
|
||||
"icon-lg": "size-10 rounded-md",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
type ButtonProps = ButtonPrimitiveProps & VariantProps<typeof buttonVariants>;
|
||||
|
||||
function Button({ className, variant, size, type = "button", ...props }: ButtonProps) {
|
||||
return <ButtonPrimitive type={type} className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
||||
}
|
||||
|
||||
export { Button, buttonVariants, type ButtonProps };
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
AccordionContent as AccordionContentPrimitive,
|
||||
type AccordionContentProps as AccordionContentPrimitiveProps,
|
||||
AccordionHeader as AccordionHeaderPrimitive,
|
||||
AccordionItem as AccordionItemPrimitive,
|
||||
type AccordionItemProps as AccordionItemPrimitiveProps,
|
||||
Accordion as AccordionPrimitive,
|
||||
type AccordionProps as AccordionPrimitiveProps,
|
||||
AccordionTrigger as AccordionTriggerPrimitive,
|
||||
type AccordionTriggerProps as AccordionTriggerPrimitiveProps,
|
||||
} from "@/components/animate-ui/primitives/radix/accordion";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type AccordionProps = AccordionPrimitiveProps;
|
||||
|
||||
function Accordion(props: AccordionProps) {
|
||||
return <AccordionPrimitive {...props} />;
|
||||
}
|
||||
|
||||
type AccordionItemProps = AccordionItemPrimitiveProps;
|
||||
|
||||
function AccordionItem({ className, ...props }: AccordionItemProps) {
|
||||
return <AccordionItemPrimitive className={cn("border-b last:border-b-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
type AccordionTriggerProps = AccordionTriggerPrimitiveProps;
|
||||
|
||||
function AccordionTrigger({ className, children, ...props }: AccordionTriggerProps) {
|
||||
return (
|
||||
<AccordionHeaderPrimitive className="flex">
|
||||
<AccordionTriggerPrimitive
|
||||
className={cn(
|
||||
"flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left font-medium text-sm outline-none transition-all hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=closed]>svg]:rotate-0 [&[data-state=open]>svg]:rotate-90 [&[data-state=open]>svg]:transition-transform",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</AccordionTriggerPrimitive>
|
||||
</AccordionHeaderPrimitive>
|
||||
);
|
||||
}
|
||||
|
||||
type AccordionContentProps = AccordionContentPrimitiveProps;
|
||||
|
||||
function AccordionContent({ className, children, ...props }: AccordionContentProps) {
|
||||
return (
|
||||
<AccordionContentPrimitive {...props}>
|
||||
<div className={cn("pt-0 pb-4 text-sm", className)}>{children}</div>
|
||||
</AccordionContentPrimitive>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
AccordionContent,
|
||||
type AccordionProps,
|
||||
type AccordionItemProps,
|
||||
type AccordionTriggerProps,
|
||||
type AccordionContentProps,
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import { buttonVariants } from "@/components/animate-ui/components/buttons/button";
|
||||
import {
|
||||
AlertDialogAction as AlertDialogActionPrimitive,
|
||||
type AlertDialogActionProps as AlertDialogActionPrimitiveProps,
|
||||
AlertDialogCancel as AlertDialogCancelPrimitive,
|
||||
type AlertDialogCancelProps as AlertDialogCancelPrimitiveProps,
|
||||
AlertDialogContent as AlertDialogContentPrimitive,
|
||||
type AlertDialogContentProps as AlertDialogContentPrimitiveProps,
|
||||
AlertDialogDescription as AlertDialogDescriptionPrimitive,
|
||||
type AlertDialogDescriptionProps as AlertDialogDescriptionPrimitiveProps,
|
||||
AlertDialogFooter as AlertDialogFooterPrimitive,
|
||||
type AlertDialogFooterProps as AlertDialogFooterPrimitiveProps,
|
||||
AlertDialogHeader as AlertDialogHeaderPrimitive,
|
||||
type AlertDialogHeaderProps as AlertDialogHeaderPrimitiveProps,
|
||||
AlertDialogOverlay as AlertDialogOverlayPrimitive,
|
||||
type AlertDialogOverlayProps as AlertDialogOverlayPrimitiveProps,
|
||||
AlertDialogPortal as AlertDialogPortalPrimitive,
|
||||
AlertDialog as AlertDialogPrimitive,
|
||||
type AlertDialogProps as AlertDialogPrimitiveProps,
|
||||
AlertDialogTitle as AlertDialogTitlePrimitive,
|
||||
type AlertDialogTitleProps as AlertDialogTitlePrimitiveProps,
|
||||
AlertDialogTrigger as AlertDialogTriggerPrimitive,
|
||||
type AlertDialogTriggerProps as AlertDialogTriggerPrimitiveProps,
|
||||
} from "@/components/animate-ui/primitives/radix/alert-dialog";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type AlertDialogProps = AlertDialogPrimitiveProps;
|
||||
|
||||
function AlertDialog(props: AlertDialogProps) {
|
||||
return <AlertDialogPrimitive {...props} />;
|
||||
}
|
||||
|
||||
type AlertDialogTriggerProps = AlertDialogTriggerPrimitiveProps;
|
||||
|
||||
function AlertDialogTrigger(props: AlertDialogTriggerProps) {
|
||||
return <AlertDialogTriggerPrimitive {...props} />;
|
||||
}
|
||||
|
||||
type AlertDialogOverlayProps = AlertDialogOverlayPrimitiveProps;
|
||||
|
||||
function AlertDialogOverlay({ className, ...props }: AlertDialogOverlayProps) {
|
||||
return <AlertDialogOverlayPrimitive className={cn("fixed inset-0 z-50 bg-black/50", className)} {...props} />;
|
||||
}
|
||||
|
||||
type AlertDialogContentProps = AlertDialogContentPrimitiveProps;
|
||||
|
||||
function AlertDialogContent({ className, ...props }: AlertDialogContentProps) {
|
||||
return (
|
||||
<AlertDialogPortalPrimitive>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogContentPrimitive
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortalPrimitive>
|
||||
);
|
||||
}
|
||||
|
||||
type AlertDialogHeaderProps = AlertDialogHeaderPrimitiveProps;
|
||||
|
||||
function AlertDialogHeader({ className, ...props }: AlertDialogHeaderProps) {
|
||||
return (
|
||||
<AlertDialogHeaderPrimitive className={cn("flex flex-col gap-2 text-center sm:text-left", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
type AlertDialogFooterProps = AlertDialogFooterPrimitiveProps;
|
||||
|
||||
function AlertDialogFooter({ className, ...props }: AlertDialogFooterProps) {
|
||||
return (
|
||||
<AlertDialogFooterPrimitive
|
||||
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type AlertDialogTitleProps = AlertDialogTitlePrimitiveProps;
|
||||
|
||||
function AlertDialogTitle({ className, ...props }: AlertDialogTitleProps) {
|
||||
return <AlertDialogTitlePrimitive className={cn("font-semibold text-lg", className)} {...props} />;
|
||||
}
|
||||
|
||||
type AlertDialogDescriptionProps = AlertDialogDescriptionPrimitiveProps;
|
||||
|
||||
function AlertDialogDescription({ className, ...props }: AlertDialogDescriptionProps) {
|
||||
return <AlertDialogDescriptionPrimitive className={cn("text-muted-foreground text-sm", className)} {...props} />;
|
||||
}
|
||||
|
||||
type AlertDialogActionProps = AlertDialogActionPrimitiveProps;
|
||||
|
||||
function AlertDialogAction({ className, ...props }: AlertDialogActionPrimitiveProps) {
|
||||
return <AlertDialogActionPrimitive className={cn(buttonVariants(), className)} {...props} />;
|
||||
}
|
||||
|
||||
type AlertDialogCancelProps = AlertDialogCancelPrimitiveProps;
|
||||
|
||||
function AlertDialogCancel({ className, ...props }: AlertDialogCancelPrimitiveProps) {
|
||||
return <AlertDialogCancelPrimitive className={cn(buttonVariants({ variant: "outline" }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
type AlertDialogProps,
|
||||
type AlertDialogTriggerProps,
|
||||
type AlertDialogContentProps,
|
||||
type AlertDialogHeaderProps,
|
||||
type AlertDialogFooterProps,
|
||||
type AlertDialogTitleProps,
|
||||
type AlertDialogDescriptionProps,
|
||||
type AlertDialogActionProps,
|
||||
type AlertDialogCancelProps,
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
DialogClose as DialogClosePrimitive,
|
||||
type DialogCloseProps as DialogClosePrimitiveProps,
|
||||
DialogContent as DialogContentPrimitive,
|
||||
type DialogContentProps as DialogContentPrimitiveProps,
|
||||
DialogDescription as DialogDescriptionPrimitive,
|
||||
type DialogDescriptionProps as DialogDescriptionPrimitiveProps,
|
||||
DialogFooter as DialogFooterPrimitive,
|
||||
type DialogFooterProps as DialogFooterPrimitiveProps,
|
||||
DialogHeader as DialogHeaderPrimitive,
|
||||
type DialogHeaderProps as DialogHeaderPrimitiveProps,
|
||||
DialogOverlay as DialogOverlayPrimitive,
|
||||
type DialogOverlayProps as DialogOverlayPrimitiveProps,
|
||||
DialogPortal as DialogPortalPrimitive,
|
||||
Dialog as DialogPrimitive,
|
||||
type DialogProps as DialogPrimitiveProps,
|
||||
DialogTitle as DialogTitlePrimitive,
|
||||
type DialogTitleProps as DialogTitlePrimitiveProps,
|
||||
DialogTrigger as DialogTriggerPrimitive,
|
||||
type DialogTriggerProps as DialogTriggerPrimitiveProps,
|
||||
} from "@/components/animate-ui/primitives/radix/dialog";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type DialogProps = DialogPrimitiveProps;
|
||||
|
||||
function Dialog(props: DialogProps) {
|
||||
return <DialogPrimitive {...props} />;
|
||||
}
|
||||
|
||||
type DialogTriggerProps = DialogTriggerPrimitiveProps;
|
||||
|
||||
function DialogTrigger(props: DialogTriggerProps) {
|
||||
return <DialogTriggerPrimitive {...props} />;
|
||||
}
|
||||
|
||||
type DialogCloseProps = DialogClosePrimitiveProps;
|
||||
|
||||
function DialogClose(props: DialogCloseProps) {
|
||||
return <DialogClosePrimitive {...props} />;
|
||||
}
|
||||
|
||||
type DialogOverlayProps = DialogOverlayPrimitiveProps;
|
||||
|
||||
function DialogOverlay({ className, ...props }: DialogOverlayProps) {
|
||||
return <DialogOverlayPrimitive className={cn("fixed inset-0 z-50 bg-black/50", className)} {...props} />;
|
||||
}
|
||||
|
||||
type DialogContentProps = DialogContentPrimitiveProps;
|
||||
|
||||
function DialogContent({ className, children, ...props }: DialogContentProps) {
|
||||
return (
|
||||
<DialogPortalPrimitive>
|
||||
<DialogOverlay />
|
||||
<DialogContentPrimitive
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg sm:max-w-2xl",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DialogContentPrimitive>
|
||||
</DialogPortalPrimitive>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogHeaderProps = DialogHeaderPrimitiveProps;
|
||||
|
||||
function DialogHeader({ className, ...props }: DialogHeaderProps) {
|
||||
return <DialogHeaderPrimitive className={cn("flex flex-col gap-2 text-center sm:text-left", className)} {...props} />;
|
||||
}
|
||||
|
||||
type DialogFooterProps = DialogFooterPrimitiveProps;
|
||||
|
||||
function DialogFooter({ className, ...props }: DialogFooterProps) {
|
||||
return (
|
||||
<DialogFooterPrimitive
|
||||
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogTitleProps = DialogTitlePrimitiveProps;
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogTitleProps) {
|
||||
return <DialogTitlePrimitive className={cn("font-semibold text-lg leading-none", className)} {...props} />;
|
||||
}
|
||||
|
||||
type DialogDescriptionProps = DialogDescriptionPrimitiveProps;
|
||||
|
||||
function DialogDescription({ className, ...props }: DialogDescriptionProps) {
|
||||
return <DialogDescriptionPrimitive className={cn("text-muted-foreground text-sm", className)} {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
type DialogProps,
|
||||
type DialogTriggerProps,
|
||||
type DialogCloseProps,
|
||||
type DialogContentProps,
|
||||
type DialogHeaderProps,
|
||||
type DialogFooterProps,
|
||||
type DialogTitleProps,
|
||||
type DialogDescriptionProps,
|
||||
};
|
||||
@@ -0,0 +1,247 @@
|
||||
import { CaretRightIcon, CheckIcon, CircleIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
DropdownMenuCheckboxItem as DropdownMenuCheckboxItemPrimitive,
|
||||
type DropdownMenuCheckboxItemProps as DropdownMenuCheckboxItemPrimitiveProps,
|
||||
DropdownMenuContent as DropdownMenuContentPrimitive,
|
||||
type DropdownMenuContentProps as DropdownMenuContentPrimitiveProps,
|
||||
DropdownMenuGroup as DropdownMenuGroupPrimitive,
|
||||
type DropdownMenuGroupProps as DropdownMenuGroupPrimitiveProps,
|
||||
DropdownMenuItemIndicator as DropdownMenuItemIndicatorPrimitive,
|
||||
DropdownMenuItem as DropdownMenuItemPrimitive,
|
||||
type DropdownMenuItemProps as DropdownMenuItemPrimitiveProps,
|
||||
DropdownMenuLabel as DropdownMenuLabelPrimitive,
|
||||
type DropdownMenuLabelProps as DropdownMenuLabelPrimitiveProps,
|
||||
DropdownMenu as DropdownMenuPrimitive,
|
||||
type DropdownMenuProps as DropdownMenuPrimitiveProps,
|
||||
DropdownMenuRadioGroup as DropdownMenuRadioGroupPrimitive,
|
||||
type DropdownMenuRadioGroupProps as DropdownMenuRadioGroupPrimitiveProps,
|
||||
DropdownMenuRadioItem as DropdownMenuRadioItemPrimitive,
|
||||
type DropdownMenuRadioItemProps as DropdownMenuRadioItemPrimitiveProps,
|
||||
DropdownMenuSeparator as DropdownMenuSeparatorPrimitive,
|
||||
type DropdownMenuSeparatorProps as DropdownMenuSeparatorPrimitiveProps,
|
||||
DropdownMenuShortcut as DropdownMenuShortcutPrimitive,
|
||||
type DropdownMenuShortcutProps as DropdownMenuShortcutPrimitiveProps,
|
||||
DropdownMenuSubContent as DropdownMenuSubContentPrimitive,
|
||||
type DropdownMenuSubContentProps as DropdownMenuSubContentPrimitiveProps,
|
||||
DropdownMenuSub as DropdownMenuSubPrimitive,
|
||||
type DropdownMenuSubProps as DropdownMenuSubPrimitiveProps,
|
||||
DropdownMenuSubTrigger as DropdownMenuSubTriggerPrimitive,
|
||||
type DropdownMenuSubTriggerProps as DropdownMenuSubTriggerPrimitiveProps,
|
||||
DropdownMenuTrigger as DropdownMenuTriggerPrimitive,
|
||||
type DropdownMenuTriggerProps as DropdownMenuTriggerPrimitiveProps,
|
||||
} from "@/components/animate-ui/primitives/radix/dropdown-menu";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type DropdownMenuProps = DropdownMenuPrimitiveProps;
|
||||
|
||||
function DropdownMenu(props: DropdownMenuProps) {
|
||||
return <DropdownMenuPrimitive {...props} />;
|
||||
}
|
||||
|
||||
type DropdownMenuTriggerProps = DropdownMenuTriggerPrimitiveProps;
|
||||
|
||||
function DropdownMenuTrigger(props: DropdownMenuTriggerProps) {
|
||||
return <DropdownMenuTriggerPrimitive {...props} />;
|
||||
}
|
||||
|
||||
type DropdownMenuContentProps = DropdownMenuContentPrimitiveProps;
|
||||
|
||||
function DropdownMenuContent({ sideOffset = 4, className, children, ...props }: DropdownMenuContentProps) {
|
||||
return (
|
||||
<DropdownMenuContentPrimitive
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DropdownMenuContentPrimitive>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuGroupProps = DropdownMenuGroupPrimitiveProps;
|
||||
|
||||
function DropdownMenuGroup({ ...props }: DropdownMenuGroupProps) {
|
||||
return <DropdownMenuGroupPrimitive {...props} />;
|
||||
}
|
||||
|
||||
type DropdownMenuItemProps = DropdownMenuItemPrimitiveProps & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
};
|
||||
|
||||
function DropdownMenuItem({ className, inset, variant = "default", disabled, ...props }: DropdownMenuItemProps) {
|
||||
return (
|
||||
<DropdownMenuItemPrimitive
|
||||
disabled={disabled}
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden data-[disabled=true]:pointer-events-none data-inset:pl-8 data-[variant=destructive]:text-destructive data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
"data-[variant=destructive]:data-highlighted:bg-destructive/10 data-[variant=destructive]:data-highlighted:text-destructive data-highlighted:bg-accent data-highlighted:text-accent-foreground dark:data-[variant=destructive]:data-highlighted:bg-destructive/20",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuCheckboxItemProps = DropdownMenuCheckboxItemPrimitiveProps;
|
||||
|
||||
function DropdownMenuCheckboxItem({ className, children, checked, disabled, ...props }: DropdownMenuCheckboxItemProps) {
|
||||
return (
|
||||
<DropdownMenuCheckboxItemPrimitive
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
"data-highlighted:bg-accent data-highlighted:text-accent-foreground",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuItemIndicatorPrimitive initial={{ opacity: 0, scale: 0.5 }} animate={{ opacity: 1, scale: 1 }}>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuItemIndicatorPrimitive>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuCheckboxItemPrimitive>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuRadioGroupProps = DropdownMenuRadioGroupPrimitiveProps;
|
||||
|
||||
function DropdownMenuRadioGroup(props: DropdownMenuRadioGroupProps) {
|
||||
return <DropdownMenuRadioGroupPrimitive {...props} />;
|
||||
}
|
||||
|
||||
type DropdownMenuRadioItemProps = DropdownMenuRadioItemPrimitiveProps;
|
||||
|
||||
function DropdownMenuRadioItem({ className, children, disabled, ...props }: DropdownMenuRadioItemProps) {
|
||||
return (
|
||||
<DropdownMenuRadioItemPrimitive
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
"data-highlighted:bg-accent data-highlighted:text-accent-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuItemIndicatorPrimitive layoutId="dropdown-menu-item-indicator-radio">
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuItemIndicatorPrimitive>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuRadioItemPrimitive>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuLabelProps = DropdownMenuLabelPrimitiveProps & {
|
||||
inset?: boolean;
|
||||
};
|
||||
|
||||
function DropdownMenuLabel({ className, inset, ...props }: DropdownMenuLabelProps) {
|
||||
return (
|
||||
<DropdownMenuLabelPrimitive
|
||||
data-inset={inset}
|
||||
className={cn("px-2 py-1.5 font-medium text-sm data-inset:pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuSeparatorProps = DropdownMenuSeparatorPrimitiveProps;
|
||||
|
||||
function DropdownMenuSeparator({ className, ...props }: DropdownMenuSeparatorProps) {
|
||||
return <DropdownMenuSeparatorPrimitive className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />;
|
||||
}
|
||||
|
||||
type DropdownMenuShortcutProps = DropdownMenuShortcutPrimitiveProps;
|
||||
|
||||
function DropdownMenuShortcut({ className, ...props }: DropdownMenuShortcutProps) {
|
||||
return (
|
||||
<DropdownMenuShortcutPrimitive
|
||||
className={cn("ml-auto text-muted-foreground text-xs tracking-widest", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuSubProps = DropdownMenuSubPrimitiveProps;
|
||||
|
||||
function DropdownMenuSub(props: DropdownMenuSubProps) {
|
||||
return <DropdownMenuSubPrimitive {...props} />;
|
||||
}
|
||||
|
||||
type DropdownMenuSubTriggerProps = DropdownMenuSubTriggerPrimitiveProps & {
|
||||
inset?: boolean;
|
||||
};
|
||||
|
||||
function DropdownMenuSubTrigger({ disabled, className, inset, children, ...props }: DropdownMenuSubTriggerProps) {
|
||||
return (
|
||||
<DropdownMenuSubTriggerPrimitive
|
||||
disabled={disabled}
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden data-inset:pl-8 data-[state=open]:text-accent-foreground",
|
||||
"data-[state=open]:**:data-[slot=chevron]:rotate-90 **:data-[slot=chevron]:transition-transform **:data-[slot=chevron]:duration-300 **:data-[slot=chevron]:ease-in-out",
|
||||
"data-highlighted:bg-accent data-highlighted:text-accent-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<CaretRightIcon data-slot="chevron" className="ml-auto size-4" />
|
||||
</DropdownMenuSubTriggerPrimitive>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuSubContentProps = DropdownMenuSubContentPrimitiveProps;
|
||||
|
||||
function DropdownMenuSubContent({ className, ...props }: DropdownMenuSubContentProps) {
|
||||
return (
|
||||
<DropdownMenuSubContentPrimitive
|
||||
className={cn(
|
||||
"z-50 min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
type DropdownMenuProps,
|
||||
type DropdownMenuTriggerProps,
|
||||
type DropdownMenuContentProps,
|
||||
type DropdownMenuGroupProps,
|
||||
type DropdownMenuItemProps,
|
||||
type DropdownMenuCheckboxItemProps,
|
||||
type DropdownMenuRadioGroupProps,
|
||||
type DropdownMenuRadioItemProps,
|
||||
type DropdownMenuLabelProps,
|
||||
type DropdownMenuSeparatorProps,
|
||||
type DropdownMenuShortcutProps,
|
||||
type DropdownMenuSubProps,
|
||||
type DropdownMenuSubTriggerProps,
|
||||
type DropdownMenuSubContentProps,
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import {
|
||||
SwitchIcon as SwitchIconPrimitive,
|
||||
Switch as SwitchPrimitive,
|
||||
type SwitchProps as SwitchPrimitiveProps,
|
||||
SwitchThumb as SwitchThumbPrimitive,
|
||||
} from "@/components/animate-ui/primitives/radix/switch";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type SwitchProps = SwitchPrimitiveProps & {
|
||||
pressedWidth?: number;
|
||||
startIcon?: React.ReactElement;
|
||||
endIcon?: React.ReactElement;
|
||||
thumbIcon?: React.ReactElement;
|
||||
};
|
||||
|
||||
function Switch({ className, pressedWidth = 20, startIcon, endIcon, thumbIcon, ...props }: SwitchProps) {
|
||||
return (
|
||||
<SwitchPrimitive
|
||||
className={cn(
|
||||
"peer relative flex h-5 w-8 shrink-0 items-center justify-start rounded-full border border-transparent px-px shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"data-[state=checked]:justify-end data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchThumbPrimitive
|
||||
className={cn(
|
||||
"pointer-events-none relative z-10 block size-4 rounded-full bg-background ring-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground",
|
||||
)}
|
||||
pressedAnimation={{ width: pressedWidth }}
|
||||
>
|
||||
{thumbIcon && (
|
||||
<SwitchIconPrimitive
|
||||
position="thumb"
|
||||
className="-translate-1/2 absolute top-1/2 left-1/2 text-neutral-400 dark:text-neutral-500 [&_svg]:size-[9px]"
|
||||
>
|
||||
{thumbIcon}
|
||||
</SwitchIconPrimitive>
|
||||
)}
|
||||
</SwitchThumbPrimitive>
|
||||
|
||||
{startIcon && (
|
||||
<SwitchIconPrimitive
|
||||
position="left"
|
||||
className="absolute top-1/2 left-0.5 -translate-y-1/2 text-neutral-400 dark:text-neutral-500 [&_svg]:size-[9px]"
|
||||
>
|
||||
{startIcon}
|
||||
</SwitchIconPrimitive>
|
||||
)}
|
||||
{endIcon && (
|
||||
<SwitchIconPrimitive
|
||||
position="right"
|
||||
className="absolute top-1/2 right-0.5 -translate-y-1/2 text-neutral-500 dark:text-neutral-400 [&_svg]:size-[9px]"
|
||||
>
|
||||
{endIcon}
|
||||
</SwitchIconPrimitive>
|
||||
)}
|
||||
</SwitchPrimitive>
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch, type SwitchProps };
|
||||
@@ -0,0 +1,67 @@
|
||||
import { type HTMLMotionProps, isMotionComponent, motion } from "motion/react";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type AnyProps = Record<string, unknown>;
|
||||
|
||||
type DOMMotionProps<T extends HTMLElement = HTMLElement> = Omit<HTMLMotionProps<keyof HTMLElementTagNameMap>, "ref"> & {
|
||||
ref?: React.Ref<T>;
|
||||
};
|
||||
|
||||
type WithAsChild<Base extends object> =
|
||||
| (Base & { asChild: true; children: React.ReactElement })
|
||||
| (Base & { asChild?: false | undefined });
|
||||
|
||||
type SlotProps<T extends HTMLElement = HTMLElement> = {
|
||||
children?: React.ReactElement;
|
||||
} & DOMMotionProps<T>;
|
||||
|
||||
function mergeRefs<T>(...refs: (React.Ref<T> | undefined)[]): React.RefCallback<T> {
|
||||
return (node) => {
|
||||
refs.forEach((ref) => {
|
||||
if (!ref) return;
|
||||
if (typeof ref === "function") {
|
||||
ref(node);
|
||||
} else {
|
||||
(ref as React.RefObject<T | null>).current = node;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function mergeProps<T extends HTMLElement>(childProps: AnyProps, slotProps: DOMMotionProps<T>): AnyProps {
|
||||
const merged: AnyProps = { ...childProps, ...slotProps };
|
||||
|
||||
if (childProps.className || slotProps.className) {
|
||||
merged.className = cn(childProps.className as string, slotProps.className as string);
|
||||
}
|
||||
|
||||
if (childProps.style || slotProps.style) {
|
||||
merged.style = {
|
||||
...(childProps.style as React.CSSProperties),
|
||||
...(slotProps.style as React.CSSProperties),
|
||||
};
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function Slot<T extends HTMLElement = HTMLElement>({ children, ref, ...props }: SlotProps<T>) {
|
||||
const isAlreadyMotion =
|
||||
children && typeof children.type === "object" && children.type !== null && isMotionComponent(children.type);
|
||||
|
||||
const Base = React.useMemo(
|
||||
() => (isAlreadyMotion ? (children.type as React.ElementType) : motion.create(children?.type as React.ElementType)),
|
||||
[isAlreadyMotion, children],
|
||||
);
|
||||
|
||||
if (!React.isValidElement(children)) return null;
|
||||
|
||||
const { ref: childRef, ...childProps } = children.props as AnyProps;
|
||||
|
||||
const mergedProps = mergeProps(childProps, props);
|
||||
|
||||
return <Base {...mergedProps} ref={mergeRefs(childRef as React.Ref<T>, ref)} />;
|
||||
}
|
||||
|
||||
export { Slot, type SlotProps, type WithAsChild, type DOMMotionProps, type AnyProps };
|
||||
@@ -0,0 +1,19 @@
|
||||
import { type HTMLMotionProps, motion } from "motion/react";
|
||||
import { Slot, type WithAsChild } from "@/components/animate-ui/primitives/animate/slot";
|
||||
|
||||
type ButtonProps = WithAsChild<
|
||||
Omit<HTMLMotionProps<"button">, "children"> & {
|
||||
hoverScale?: number;
|
||||
tapScale?: number;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: unknown type
|
||||
children?: any;
|
||||
}
|
||||
>;
|
||||
|
||||
function Button({ hoverScale = 1.0, tapScale = 0.95, asChild = false, ...props }: ButtonProps) {
|
||||
const Component = asChild ? Slot : motion.button;
|
||||
|
||||
return <Component whileTap={{ scale: tapScale }} whileHover={{ scale: hoverScale }} {...props} />;
|
||||
}
|
||||
|
||||
export { Button, type ButtonProps };
|
||||
@@ -0,0 +1,145 @@
|
||||
import { AnimatePresence, type HTMLMotionProps, motion } from "motion/react";
|
||||
import { Accordion as AccordionPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
import { getStrictContext } from "@/utils/get-strict-context";
|
||||
|
||||
type AccordionContextType = {
|
||||
value: string | string[] | undefined;
|
||||
setValue: (value: string | string[] | undefined) => void;
|
||||
};
|
||||
|
||||
type AccordionItemContextType = {
|
||||
value: string;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const [AccordionProvider, useAccordion] = getStrictContext<AccordionContextType>("AccordionContext");
|
||||
|
||||
const [AccordionItemProvider, useAccordionItem] = getStrictContext<AccordionItemContextType>("AccordionItemContext");
|
||||
|
||||
type AccordionProps = React.ComponentProps<typeof AccordionPrimitive.Root>;
|
||||
|
||||
function Accordion(props: AccordionProps) {
|
||||
const [value, setValue] = useControlledState<string | string[] | undefined>({
|
||||
value: props?.value,
|
||||
defaultValue: props?.defaultValue,
|
||||
onChange: props?.onValueChange as (value: string | string[] | undefined) => void,
|
||||
});
|
||||
|
||||
return (
|
||||
<AccordionProvider value={{ value, setValue }}>
|
||||
<AccordionPrimitive.Root data-slot="accordion" {...props} onValueChange={setValue} />
|
||||
</AccordionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type AccordionItemProps = React.ComponentProps<typeof AccordionPrimitive.Item>;
|
||||
|
||||
function AccordionItem(props: AccordionItemProps) {
|
||||
const { value } = useAccordion();
|
||||
const [isOpen, setIsOpen] = React.useState(value?.includes(props?.value) ?? false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsOpen(value?.includes(props?.value) ?? false);
|
||||
}, [value, props?.value]);
|
||||
|
||||
return (
|
||||
<AccordionItemProvider value={{ isOpen, setIsOpen, value: props.value }}>
|
||||
<AccordionPrimitive.Item data-slot="accordion-item" {...props} />
|
||||
</AccordionItemProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type AccordionHeaderProps = React.ComponentProps<typeof AccordionPrimitive.Header>;
|
||||
|
||||
function AccordionHeader(props: AccordionHeaderProps) {
|
||||
return <AccordionPrimitive.Header data-slot="accordion-header" {...props} />;
|
||||
}
|
||||
|
||||
type AccordionTriggerProps = React.ComponentProps<typeof AccordionPrimitive.Trigger>;
|
||||
|
||||
function AccordionTrigger(props: AccordionTriggerProps) {
|
||||
return <AccordionPrimitive.Trigger data-slot="accordion-trigger" {...props} />;
|
||||
}
|
||||
|
||||
type AccordionContentProps = Omit<React.ComponentProps<typeof AccordionPrimitive.Content>, "asChild" | "forceMount"> &
|
||||
HTMLMotionProps<"div"> & {
|
||||
keepRendered?: boolean;
|
||||
};
|
||||
|
||||
function AccordionContent({
|
||||
keepRendered = false,
|
||||
transition = { duration: 0.35, ease: "easeInOut" },
|
||||
...props
|
||||
}: AccordionContentProps) {
|
||||
const { isOpen } = useAccordionItem();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{keepRendered ? (
|
||||
<AccordionPrimitive.Content asChild forceMount>
|
||||
<motion.div
|
||||
key="accordion-content"
|
||||
data-slot="accordion-content"
|
||||
initial={{ height: 0, opacity: 0, "--mask-stop": "0%", y: 20 }}
|
||||
animate={
|
||||
isOpen
|
||||
? { height: "auto", opacity: 1, "--mask-stop": "100%", y: 0 }
|
||||
: { height: 0, opacity: 0, "--mask-stop": "0%", y: 20 }
|
||||
}
|
||||
transition={transition}
|
||||
style={{
|
||||
maskImage: "linear-gradient(black var(--mask-stop), transparent var(--mask-stop))",
|
||||
WebkitMaskImage: "linear-gradient(black var(--mask-stop), transparent var(--mask-stop))",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</AccordionPrimitive.Content>
|
||||
) : (
|
||||
isOpen && (
|
||||
<AccordionPrimitive.Content asChild forceMount>
|
||||
<motion.div
|
||||
key="accordion-content"
|
||||
data-slot="accordion-content"
|
||||
initial={{ height: 0, opacity: 0, "--mask-stop": "0%", y: 20 }}
|
||||
animate={{
|
||||
height: "auto",
|
||||
opacity: 1,
|
||||
"--mask-stop": "100%",
|
||||
y: 0,
|
||||
}}
|
||||
exit={{ height: 0, opacity: 0, "--mask-stop": "0%", y: 20 }}
|
||||
transition={transition}
|
||||
style={{
|
||||
maskImage: "linear-gradient(black var(--mask-stop), transparent var(--mask-stop))",
|
||||
WebkitMaskImage: "linear-gradient(black var(--mask-stop), transparent var(--mask-stop))",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionHeader,
|
||||
AccordionTrigger,
|
||||
AccordionContent,
|
||||
useAccordion,
|
||||
useAccordionItem,
|
||||
type AccordionProps,
|
||||
type AccordionItemProps,
|
||||
type AccordionHeaderProps,
|
||||
type AccordionTriggerProps,
|
||||
type AccordionContentProps,
|
||||
type AccordionContextType,
|
||||
type AccordionItemContextType,
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
import { AnimatePresence, type HTMLMotionProps, motion } from "motion/react";
|
||||
import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
import { getStrictContext } from "@/utils/get-strict-context";
|
||||
|
||||
type AlertDialogContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: AlertDialogProps["onOpenChange"];
|
||||
};
|
||||
|
||||
const [AlertDialogProvider, useAlertDialog] = getStrictContext<AlertDialogContextType>("AlertDialogContext");
|
||||
|
||||
type AlertDialogProps = React.ComponentProps<typeof AlertDialogPrimitive.Root>;
|
||||
|
||||
function AlertDialog(props: AlertDialogProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
onChange: props?.onOpenChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<AlertDialogProvider value={{ isOpen, setIsOpen }}>
|
||||
<AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} onOpenChange={setIsOpen} />
|
||||
</AlertDialogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type AlertDialogTriggerProps = React.ComponentProps<typeof AlertDialogPrimitive.Trigger>;
|
||||
|
||||
function AlertDialogTrigger(props: AlertDialogTriggerProps) {
|
||||
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
type AlertDialogPortalProps = Omit<React.ComponentProps<typeof AlertDialogPrimitive.Portal>, "forceMount">;
|
||||
|
||||
function AlertDialogPortal(props: AlertDialogPortalProps) {
|
||||
const { isOpen } = useAlertDialog();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" forceMount {...props} />}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type AlertDialogOverlayProps = Omit<
|
||||
React.ComponentProps<typeof AlertDialogPrimitive.Overlay>,
|
||||
"forceMount" | "asChild"
|
||||
> &
|
||||
HTMLMotionProps<"div">;
|
||||
|
||||
function AlertDialogOverlay({ transition = { duration: 0.2, ease: "easeInOut" }, ...props }: AlertDialogOverlayProps) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay data-slot="alert-dialog-overlay" asChild forceMount>
|
||||
<motion.div
|
||||
key="alert-dialog-overlay"
|
||||
initial={{ opacity: 0, filter: "blur(4px)" }}
|
||||
animate={{ opacity: 1, filter: "blur(0px)" }}
|
||||
exit={{ opacity: 0, filter: "blur(4px)" }}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPrimitive.Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
type AlertDialogFlipDirection = "top" | "bottom" | "left" | "right";
|
||||
|
||||
type AlertDialogContentProps = Omit<
|
||||
React.ComponentProps<typeof AlertDialogPrimitive.Content>,
|
||||
"forceMount" | "asChild"
|
||||
> &
|
||||
HTMLMotionProps<"div"> & {
|
||||
from?: AlertDialogFlipDirection;
|
||||
};
|
||||
|
||||
function AlertDialogContent({
|
||||
from = "top",
|
||||
onOpenAutoFocus,
|
||||
onCloseAutoFocus,
|
||||
onEscapeKeyDown,
|
||||
transition = { type: "spring", stiffness: 150, damping: 25 },
|
||||
...props
|
||||
}: AlertDialogContentProps) {
|
||||
const initialRotation = from === "bottom" || from === "left" ? "20deg" : "-20deg";
|
||||
const isVertical = from === "top" || from === "bottom";
|
||||
const rotateAxis = isVertical ? "rotateX" : "rotateY";
|
||||
|
||||
return (
|
||||
<AlertDialogPrimitive.Content
|
||||
asChild
|
||||
forceMount
|
||||
onOpenAutoFocus={onOpenAutoFocus}
|
||||
onCloseAutoFocus={onCloseAutoFocus}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
>
|
||||
<motion.div
|
||||
key="alert-dialog-content"
|
||||
data-slot="alert-dialog-content"
|
||||
initial={{
|
||||
opacity: 0,
|
||||
filter: "blur(4px)",
|
||||
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
transform: `perspective(500px) ${rotateAxis}(0deg) scale(1)`,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
filter: "blur(4px)",
|
||||
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
|
||||
}}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
type AlertDialogCancelProps = React.ComponentProps<typeof AlertDialogPrimitive.Cancel>;
|
||||
|
||||
function AlertDialogCancel(props: AlertDialogCancelProps) {
|
||||
return <AlertDialogPrimitive.Cancel data-slot="alert-dialog-cancel" {...props} />;
|
||||
}
|
||||
|
||||
type AlertDialogActionProps = React.ComponentProps<typeof AlertDialogPrimitive.Action>;
|
||||
|
||||
function AlertDialogAction(props: AlertDialogActionProps) {
|
||||
return <AlertDialogPrimitive.Action data-slot="alert-dialog-action" {...props} />;
|
||||
}
|
||||
|
||||
type AlertDialogHeaderProps = React.ComponentProps<"div">;
|
||||
|
||||
function AlertDialogHeader(props: AlertDialogHeaderProps) {
|
||||
return <div data-slot="alert-dialog-header" {...props} />;
|
||||
}
|
||||
|
||||
type AlertDialogFooterProps = React.ComponentProps<"div">;
|
||||
|
||||
function AlertDialogFooter(props: AlertDialogFooterProps) {
|
||||
return <div data-slot="alert-dialog-footer" {...props} />;
|
||||
}
|
||||
|
||||
type AlertDialogTitleProps = React.ComponentProps<typeof AlertDialogPrimitive.Title>;
|
||||
|
||||
function AlertDialogTitle(props: AlertDialogTitleProps) {
|
||||
return <AlertDialogPrimitive.Title data-slot="alert-dialog-title" {...props} />;
|
||||
}
|
||||
|
||||
type AlertDialogDescriptionProps = React.ComponentProps<typeof AlertDialogPrimitive.Description>;
|
||||
|
||||
function AlertDialogDescription(props: AlertDialogDescriptionProps) {
|
||||
return <AlertDialogPrimitive.Description data-slot="alert-dialog-description" {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
useAlertDialog,
|
||||
type AlertDialogProps,
|
||||
type AlertDialogTriggerProps,
|
||||
type AlertDialogPortalProps,
|
||||
type AlertDialogCancelProps,
|
||||
type AlertDialogActionProps,
|
||||
type AlertDialogOverlayProps,
|
||||
type AlertDialogContentProps,
|
||||
type AlertDialogHeaderProps,
|
||||
type AlertDialogFooterProps,
|
||||
type AlertDialogTitleProps,
|
||||
type AlertDialogDescriptionProps,
|
||||
type AlertDialogContextType,
|
||||
type AlertDialogFlipDirection,
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import { AnimatePresence, type HTMLMotionProps, motion } from "motion/react";
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
import { getStrictContext } from "@/utils/get-strict-context";
|
||||
|
||||
type DialogContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: DialogProps["onOpenChange"];
|
||||
};
|
||||
|
||||
const [DialogProvider, useDialog] = getStrictContext<DialogContextType>("DialogContext");
|
||||
|
||||
type DialogProps = React.ComponentProps<typeof DialogPrimitive.Root>;
|
||||
|
||||
function Dialog(props: DialogProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
onChange: props?.onOpenChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<DialogProvider value={{ isOpen, setIsOpen }}>
|
||||
<DialogPrimitive.Root data-slot="dialog" {...props} onOpenChange={setIsOpen} />
|
||||
</DialogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogTriggerProps = React.ComponentProps<typeof DialogPrimitive.Trigger>;
|
||||
|
||||
function DialogTrigger(props: DialogTriggerProps) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
type DialogPortalProps = Omit<React.ComponentProps<typeof DialogPrimitive.Portal>, "forceMount">;
|
||||
|
||||
function DialogPortal(props: DialogPortalProps) {
|
||||
const { isOpen } = useDialog();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && <DialogPrimitive.Portal data-slot="dialog-portal" forceMount {...props} />}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogOverlayProps = Omit<React.ComponentProps<typeof DialogPrimitive.Overlay>, "forceMount" | "asChild"> &
|
||||
HTMLMotionProps<"div">;
|
||||
|
||||
function DialogOverlay({ transition = { duration: 0.2, ease: "easeInOut" }, ...props }: DialogOverlayProps) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay data-slot="dialog-overlay" asChild forceMount>
|
||||
<motion.div
|
||||
key="dialog-overlay"
|
||||
initial={{ opacity: 0, filter: "blur(4px)" }}
|
||||
animate={{ opacity: 1, filter: "blur(0px)" }}
|
||||
exit={{ opacity: 0, filter: "blur(4px)" }}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
</DialogPrimitive.Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogFlipDirection = "top" | "bottom" | "left" | "right";
|
||||
|
||||
type DialogContentProps = Omit<React.ComponentProps<typeof DialogPrimitive.Content>, "forceMount" | "asChild"> &
|
||||
HTMLMotionProps<"div"> & {
|
||||
from?: DialogFlipDirection;
|
||||
};
|
||||
|
||||
function DialogContent({
|
||||
from = "top",
|
||||
onOpenAutoFocus,
|
||||
onCloseAutoFocus,
|
||||
onEscapeKeyDown,
|
||||
onPointerDownOutside,
|
||||
onInteractOutside,
|
||||
transition = { type: "spring", stiffness: 150, damping: 25 },
|
||||
...props
|
||||
}: DialogContentProps) {
|
||||
const initialRotation = from === "bottom" || from === "left" ? "20deg" : "-20deg";
|
||||
const isVertical = from === "top" || from === "bottom";
|
||||
const rotateAxis = isVertical ? "rotateX" : "rotateY";
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Content
|
||||
asChild
|
||||
forceMount
|
||||
onOpenAutoFocus={onOpenAutoFocus}
|
||||
onCloseAutoFocus={onCloseAutoFocus}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
onInteractOutside={onInteractOutside}
|
||||
>
|
||||
<motion.div
|
||||
key="dialog-content"
|
||||
data-slot="dialog-content"
|
||||
initial={{
|
||||
opacity: 0,
|
||||
filter: "blur(4px)",
|
||||
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
transform: `perspective(500px) ${rotateAxis}(0deg) scale(1)`,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
filter: "blur(4px)",
|
||||
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
|
||||
}}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
</DialogPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogCloseProps = React.ComponentProps<typeof DialogPrimitive.Close>;
|
||||
|
||||
function DialogClose(props: DialogCloseProps) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
type DialogHeaderProps = React.ComponentProps<"div">;
|
||||
|
||||
function DialogHeader(props: DialogHeaderProps) {
|
||||
return <div data-slot="dialog-header" {...props} />;
|
||||
}
|
||||
|
||||
type DialogFooterProps = React.ComponentProps<"div">;
|
||||
|
||||
function DialogFooter(props: DialogFooterProps) {
|
||||
return <div data-slot="dialog-footer" {...props} />;
|
||||
}
|
||||
|
||||
type DialogTitleProps = React.ComponentProps<typeof DialogPrimitive.Title>;
|
||||
|
||||
function DialogTitle(props: DialogTitleProps) {
|
||||
return <DialogPrimitive.Title data-slot="dialog-title" {...props} />;
|
||||
}
|
||||
|
||||
type DialogDescriptionProps = React.ComponentProps<typeof DialogPrimitive.Description>;
|
||||
|
||||
function DialogDescription(props: DialogDescriptionProps) {
|
||||
return <DialogPrimitive.Description data-slot="dialog-description" {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
useDialog,
|
||||
type DialogProps,
|
||||
type DialogTriggerProps,
|
||||
type DialogPortalProps,
|
||||
type DialogCloseProps,
|
||||
type DialogOverlayProps,
|
||||
type DialogContentProps,
|
||||
type DialogHeaderProps,
|
||||
type DialogFooterProps,
|
||||
type DialogTitleProps,
|
||||
type DialogDescriptionProps,
|
||||
type DialogContextType,
|
||||
type DialogFlipDirection,
|
||||
};
|
||||
@@ -0,0 +1,348 @@
|
||||
import { AnimatePresence, type HTMLMotionProps, motion } from "motion/react";
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
import { getStrictContext } from "@/utils/get-strict-context";
|
||||
|
||||
type DropdownMenuContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (o: boolean) => void;
|
||||
};
|
||||
|
||||
type DropdownMenuSubContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (o: boolean) => void;
|
||||
};
|
||||
|
||||
const [DropdownMenuProvider, useDropdownMenu] = getStrictContext<DropdownMenuContextType>("DropdownMenuContext");
|
||||
|
||||
const [DropdownMenuSubProvider, useDropdownMenuSub] =
|
||||
getStrictContext<DropdownMenuSubContextType>("DropdownMenuSubContext");
|
||||
|
||||
type DropdownMenuProps = React.ComponentProps<typeof DropdownMenuPrimitive.Root>;
|
||||
|
||||
function DropdownMenu(props: DropdownMenuProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
onChange: props?.onOpenChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenuProvider value={{ isOpen, setIsOpen }}>
|
||||
<DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} onOpenChange={setIsOpen} />
|
||||
</DropdownMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuTriggerProps = React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>;
|
||||
|
||||
function DropdownMenuTrigger(props: DropdownMenuTriggerProps) {
|
||||
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||
}
|
||||
|
||||
type DropdownMenuPortalProps = React.ComponentProps<typeof DropdownMenuPrimitive.Portal>;
|
||||
|
||||
function DropdownMenuPortal(props: DropdownMenuPortalProps) {
|
||||
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||
}
|
||||
|
||||
type DropdownMenuGroupProps = React.ComponentProps<typeof DropdownMenuPrimitive.Group>;
|
||||
|
||||
function DropdownMenuGroup(props: DropdownMenuGroupProps) {
|
||||
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||
}
|
||||
|
||||
type DropdownMenuSubProps = React.ComponentProps<typeof DropdownMenuPrimitive.Sub>;
|
||||
|
||||
function DropdownMenuSub(props: DropdownMenuSubProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
onChange: props?.onOpenChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenuSubProvider value={{ isOpen, setIsOpen }}>
|
||||
<DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} onOpenChange={setIsOpen} />
|
||||
</DropdownMenuSubProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuRadioGroupProps = React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>;
|
||||
|
||||
function DropdownMenuRadioGroup(props: DropdownMenuRadioGroupProps) {
|
||||
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||
}
|
||||
|
||||
type DropdownMenuSubTriggerProps = Omit<React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger>, "asChild"> &
|
||||
HTMLMotionProps<"div">;
|
||||
|
||||
function DropdownMenuSubTrigger({ disabled, textValue, ...props }: DropdownMenuSubTriggerProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger disabled={disabled} textValue={textValue} asChild>
|
||||
<motion.div data-slot="dropdown-menu-sub-trigger" data-disabled={disabled} {...props} />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuSubContentProps = Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>,
|
||||
"forceMount" | "asChild"
|
||||
> &
|
||||
Omit<React.ComponentProps<typeof DropdownMenuPrimitive.Portal>, "forceMount"> &
|
||||
HTMLMotionProps<"div">;
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
loop,
|
||||
onEscapeKeyDown,
|
||||
onPointerDownOutside,
|
||||
onFocusOutside,
|
||||
onInteractOutside,
|
||||
sideOffset,
|
||||
alignOffset,
|
||||
avoidCollisions,
|
||||
collisionBoundary,
|
||||
collisionPadding,
|
||||
arrowPadding,
|
||||
sticky,
|
||||
hideWhenDetached,
|
||||
transition = { duration: 0.2 },
|
||||
style,
|
||||
container,
|
||||
...props
|
||||
}: DropdownMenuSubContentProps) {
|
||||
const { isOpen } = useDropdownMenuSub();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<DropdownMenuPortal forceMount container={container}>
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
asChild
|
||||
forceMount
|
||||
loop={loop}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
onFocusOutside={onFocusOutside}
|
||||
onInteractOutside={onInteractOutside}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
avoidCollisions={avoidCollisions}
|
||||
collisionBoundary={collisionBoundary}
|
||||
collisionPadding={collisionPadding}
|
||||
arrowPadding={arrowPadding}
|
||||
sticky={sticky}
|
||||
hideWhenDetached={hideWhenDetached}
|
||||
>
|
||||
<motion.div
|
||||
key="dropdown-menu-sub-content"
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={transition}
|
||||
style={{ willChange: "opacity, transform", ...style }}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.SubContent>
|
||||
</DropdownMenuPortal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuContentProps = Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.Content>,
|
||||
"forceMount" | "asChild"
|
||||
> &
|
||||
Omit<React.ComponentProps<typeof DropdownMenuPrimitive.Portal>, "forceMount"> &
|
||||
HTMLMotionProps<"div">;
|
||||
|
||||
function DropdownMenuContent({
|
||||
loop,
|
||||
onCloseAutoFocus,
|
||||
onEscapeKeyDown,
|
||||
onPointerDownOutside,
|
||||
onFocusOutside,
|
||||
onInteractOutside,
|
||||
side,
|
||||
sideOffset,
|
||||
align,
|
||||
alignOffset,
|
||||
avoidCollisions,
|
||||
collisionBoundary,
|
||||
collisionPadding,
|
||||
arrowPadding,
|
||||
sticky,
|
||||
hideWhenDetached,
|
||||
transition = { duration: 0.2 },
|
||||
style,
|
||||
container,
|
||||
...props
|
||||
}: DropdownMenuContentProps) {
|
||||
const { isOpen } = useDropdownMenu();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<DropdownMenuPortal forceMount container={container}>
|
||||
<DropdownMenuPrimitive.Content
|
||||
asChild
|
||||
loop={loop}
|
||||
onCloseAutoFocus={onCloseAutoFocus}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
onFocusOutside={onFocusOutside}
|
||||
onInteractOutside={onInteractOutside}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
avoidCollisions={avoidCollisions}
|
||||
collisionBoundary={collisionBoundary}
|
||||
collisionPadding={collisionPadding}
|
||||
arrowPadding={arrowPadding}
|
||||
sticky={sticky}
|
||||
hideWhenDetached={hideWhenDetached}
|
||||
>
|
||||
<motion.div
|
||||
key="dropdown-menu-content"
|
||||
data-slot="dropdown-menu-content"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={transition}
|
||||
style={{ willChange: "opacity, transform", ...style }}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</DropdownMenuPortal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuItemProps = Omit<React.ComponentProps<typeof DropdownMenuPrimitive.Item>, "asChild"> &
|
||||
HTMLMotionProps<"div">;
|
||||
|
||||
function DropdownMenuItem({ disabled, onSelect, textValue, ...props }: DropdownMenuItemProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item disabled={disabled} onSelect={onSelect} textValue={textValue} asChild>
|
||||
<motion.div data-slot="dropdown-menu-item" data-disabled={disabled} {...props} />
|
||||
</DropdownMenuPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuCheckboxItemProps = Omit<React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>, "asChild"> &
|
||||
HTMLMotionProps<"div">;
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
disabled,
|
||||
onSelect,
|
||||
textValue,
|
||||
...props
|
||||
}: DropdownMenuCheckboxItemProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
onSelect={onSelect}
|
||||
textValue={textValue}
|
||||
asChild
|
||||
>
|
||||
<motion.div data-slot="dropdown-menu-checkbox-item" data-disabled={disabled} {...props} />
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuRadioItemProps = Omit<React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>, "asChild"> &
|
||||
HTMLMotionProps<"div">;
|
||||
|
||||
function DropdownMenuRadioItem({ value, disabled, onSelect, textValue, ...props }: DropdownMenuRadioItemProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onSelect={onSelect}
|
||||
textValue={textValue}
|
||||
asChild
|
||||
>
|
||||
<motion.div data-slot="dropdown-menu-radio-item" data-disabled={disabled} {...props} />
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuLabelProps = React.ComponentProps<typeof DropdownMenuPrimitive.Label>;
|
||||
|
||||
function DropdownMenuLabel(props: DropdownMenuLabelProps) {
|
||||
return <DropdownMenuPrimitive.Label data-slot="dropdown-menu-label" {...props} />;
|
||||
}
|
||||
|
||||
type DropdownMenuSeparatorProps = React.ComponentProps<typeof DropdownMenuPrimitive.Separator>;
|
||||
|
||||
function DropdownMenuSeparator(props: DropdownMenuSeparatorProps) {
|
||||
return <DropdownMenuPrimitive.Separator data-slot="dropdown-menu-separator" {...props} />;
|
||||
}
|
||||
|
||||
type DropdownMenuShortcutProps = React.ComponentProps<"span">;
|
||||
|
||||
function DropdownMenuShortcut(props: DropdownMenuShortcutProps) {
|
||||
return <span data-slot="dropdown-menu-shortcut" {...props} />;
|
||||
}
|
||||
|
||||
type DropdownMenuItemIndicatorProps = Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.ItemIndicator>,
|
||||
"asChild"
|
||||
> &
|
||||
HTMLMotionProps<"div">;
|
||||
|
||||
function DropdownMenuItemIndicator(props: DropdownMenuItemIndicatorProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.ItemIndicator data-slot="dropdown-menu-item-indicator" asChild>
|
||||
<motion.div {...props} />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuItemIndicator,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
useDropdownMenu,
|
||||
useDropdownMenuSub,
|
||||
type DropdownMenuProps,
|
||||
type DropdownMenuTriggerProps,
|
||||
type DropdownMenuContentProps,
|
||||
type DropdownMenuItemProps,
|
||||
type DropdownMenuItemIndicatorProps,
|
||||
type DropdownMenuCheckboxItemProps,
|
||||
type DropdownMenuRadioItemProps,
|
||||
type DropdownMenuLabelProps,
|
||||
type DropdownMenuSeparatorProps,
|
||||
type DropdownMenuShortcutProps,
|
||||
type DropdownMenuGroupProps,
|
||||
type DropdownMenuPortalProps,
|
||||
type DropdownMenuSubProps,
|
||||
type DropdownMenuSubContentProps,
|
||||
type DropdownMenuSubTriggerProps,
|
||||
type DropdownMenuRadioGroupProps,
|
||||
type DropdownMenuContextType,
|
||||
type DropdownMenuSubContextType,
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { type HTMLMotionProps, motion, type TargetAndTransition, type VariantLabels } from "motion/react";
|
||||
import { Switch as SwitchPrimitives } from "radix-ui";
|
||||
import * as React from "react";
|
||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
import { getStrictContext } from "@/utils/get-strict-context";
|
||||
|
||||
type SwitchContextType = {
|
||||
isChecked: boolean;
|
||||
setIsChecked: (isChecked: boolean) => void;
|
||||
isPressed: boolean;
|
||||
setIsPressed: (isPressed: boolean) => void;
|
||||
};
|
||||
|
||||
const [SwitchProvider, useSwitch] = getStrictContext<SwitchContextType>("SwitchContext");
|
||||
|
||||
type SwitchProps = Omit<React.ComponentProps<typeof SwitchPrimitives.Root>, "asChild"> & HTMLMotionProps<"button">;
|
||||
|
||||
function Switch(props: SwitchProps) {
|
||||
const [isPressed, setIsPressed] = React.useState(false);
|
||||
const { checked, defaultChecked, onCheckedChange, ...restProps } = props;
|
||||
|
||||
const [isChecked, setIsChecked] = useControlledState({
|
||||
value: checked,
|
||||
defaultValue: defaultChecked,
|
||||
onChange: onCheckedChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<SwitchProvider value={{ isChecked, setIsChecked, isPressed, setIsPressed }}>
|
||||
<SwitchPrimitives.Root
|
||||
{...restProps}
|
||||
asChild
|
||||
checked={isChecked}
|
||||
defaultChecked={defaultChecked}
|
||||
onCheckedChange={setIsChecked}
|
||||
>
|
||||
<motion.button
|
||||
{...restProps}
|
||||
whileTap="tap"
|
||||
initial={false}
|
||||
data-slot="switch"
|
||||
onTap={() => setIsPressed(false)}
|
||||
onTapStart={() => setIsPressed(true)}
|
||||
onTapCancel={() => setIsPressed(false)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
</SwitchProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type SwitchThumbProps = Omit<React.ComponentProps<typeof SwitchPrimitives.Thumb>, "asChild"> &
|
||||
HTMLMotionProps<"div"> & {
|
||||
pressedAnimation?: TargetAndTransition | VariantLabels | boolean;
|
||||
};
|
||||
|
||||
function SwitchThumb({
|
||||
pressedAnimation,
|
||||
transition = { type: "spring", stiffness: 300, damping: 25 },
|
||||
...props
|
||||
}: SwitchThumbProps) {
|
||||
const { isPressed } = useSwitch();
|
||||
|
||||
return (
|
||||
<SwitchPrimitives.Thumb asChild>
|
||||
<motion.div
|
||||
layout
|
||||
whileTap="tab"
|
||||
data-slot="switch-thumb"
|
||||
transition={transition}
|
||||
animate={isPressed ? pressedAnimation : undefined}
|
||||
{...props}
|
||||
/>
|
||||
</SwitchPrimitives.Thumb>
|
||||
);
|
||||
}
|
||||
|
||||
type SwitchIconPosition = "left" | "right" | "thumb";
|
||||
|
||||
type SwitchIconProps = HTMLMotionProps<"div"> & {
|
||||
position: SwitchIconPosition;
|
||||
};
|
||||
|
||||
function SwitchIcon({ position, transition = { type: "spring", bounce: 0 }, ...props }: SwitchIconProps) {
|
||||
const { isChecked } = useSwitch();
|
||||
|
||||
const isAnimated = React.useMemo(() => {
|
||||
if (position === "right") return !isChecked;
|
||||
if (position === "left") return isChecked;
|
||||
if (position === "thumb") return true;
|
||||
return false;
|
||||
}, [position, isChecked]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
data-slot={`switch-${position}-icon`}
|
||||
animate={isAnimated ? { scale: 1, opacity: 1 } : { scale: 0, opacity: 0 }}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Switch,
|
||||
SwitchThumb,
|
||||
SwitchIcon,
|
||||
useSwitch,
|
||||
type SwitchProps,
|
||||
type SwitchThumbProps,
|
||||
type SwitchIconProps,
|
||||
type SwitchIconPosition,
|
||||
type SwitchContextType,
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { motion, useMotionTemplate, useMotionValue, useSpring, useTransform } from "motion/react";
|
||||
import type React from "react";
|
||||
import { useRef } from "react";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type Props = {
|
||||
rotateDepth?: number;
|
||||
translateDepth?: number;
|
||||
glareOpacity?: number;
|
||||
scaleFactor?: number;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const CometCard = ({
|
||||
rotateDepth = 17.5,
|
||||
translateDepth = 20,
|
||||
glareOpacity = 0.4,
|
||||
scaleFactor = 1.05,
|
||||
className,
|
||||
children,
|
||||
}: Props) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const x = useMotionValue(0);
|
||||
const y = useMotionValue(0);
|
||||
|
||||
const mouseXSpring = useSpring(x);
|
||||
const mouseYSpring = useSpring(y);
|
||||
|
||||
const rotateX = useTransform(mouseYSpring, [-0.5, 0.5], [`-${rotateDepth}deg`, `${rotateDepth}deg`]);
|
||||
const rotateY = useTransform(mouseXSpring, [-0.5, 0.5], [`${rotateDepth}deg`, `-${rotateDepth}deg`]);
|
||||
|
||||
const translateX = useTransform(mouseXSpring, [-0.5, 0.5], [`-${translateDepth}px`, `${translateDepth}px`]);
|
||||
const translateY = useTransform(mouseYSpring, [-0.5, 0.5], [`${translateDepth}px`, `-${translateDepth}px`]);
|
||||
|
||||
const glareX = useTransform(mouseXSpring, [-0.5, 0.5], [0, 100]);
|
||||
const glareY = useTransform(mouseYSpring, [-0.5, 0.5], [0, 100]);
|
||||
|
||||
const glareBackground = useMotionTemplate`radial-gradient(circle at ${glareX}% ${glareY}%, rgba(255, 255, 255, 0.9) 10%, rgba(255, 255, 255, 0.75) 20%, rgba(255, 255, 255, 0) 80%)`;
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!ref.current) return;
|
||||
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
const xPct = mouseX / width - 0.5;
|
||||
const yPct = mouseY / height - 0.5;
|
||||
|
||||
x.set(xPct);
|
||||
y.set(yPct);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
x.set(0);
|
||||
y.set(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("perspective-distant transform-3d", className)}>
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ scale: 1, z: 0 }}
|
||||
className="relative rounded-md"
|
||||
style={{ rotateX, rotateY, translateX, translateY }}
|
||||
whileHover={{ z: 50, scale: scaleFactor, transition: { duration: 0.2 } }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{children}
|
||||
|
||||
<motion.div
|
||||
transition={{ duration: 0.2 }}
|
||||
style={{ background: glareBackground, opacity: glareOpacity }}
|
||||
className="pointer-events-none absolute inset-0 z-50 h-full w-full rounded-md mix-blend-overlay"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useInView, useMotionValue, useSpring } from "motion/react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
type CountUpProps = {
|
||||
to: number;
|
||||
from?: number;
|
||||
direction?: "up" | "down";
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
className?: string;
|
||||
startWhen?: boolean;
|
||||
separator?: string;
|
||||
onStart?: () => void;
|
||||
onEnd?: () => void;
|
||||
"aria-hidden"?: boolean | "true" | "false";
|
||||
"aria-live"?: "off" | "polite" | "assertive";
|
||||
"aria-atomic"?: boolean | "true" | "false";
|
||||
};
|
||||
|
||||
export function CountUp({
|
||||
to,
|
||||
from = 0,
|
||||
direction = "up",
|
||||
delay = 0,
|
||||
duration = 2,
|
||||
className = "",
|
||||
startWhen = true,
|
||||
separator = "",
|
||||
onStart,
|
||||
onEnd,
|
||||
"aria-hidden": ariaHidden,
|
||||
"aria-live": ariaLive = "polite",
|
||||
"aria-atomic": ariaAtomic = "true",
|
||||
}: CountUpProps) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const motionValue = useMotionValue(direction === "down" ? to : from);
|
||||
|
||||
const damping = 20 + 40 * (1 / duration);
|
||||
const stiffness = 100 * (1 / duration);
|
||||
|
||||
const springValue = useSpring(motionValue, {
|
||||
damping,
|
||||
stiffness,
|
||||
});
|
||||
|
||||
const isInView = useInView(ref, { once: true, margin: "0px" });
|
||||
|
||||
const getDecimalPlaces = (num: number): number => {
|
||||
const str = num.toString();
|
||||
|
||||
if (str.includes(".")) {
|
||||
const decimals = str.split(".")[1];
|
||||
if (Number.parseInt(decimals, 10) !== 0) return decimals.length;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const maxDecimals = Math.max(getDecimalPlaces(from), getDecimalPlaces(to));
|
||||
|
||||
const formatValue = useCallback(
|
||||
(latest: number) => {
|
||||
const hasDecimals = maxDecimals > 0;
|
||||
|
||||
const options: Intl.NumberFormatOptions = {
|
||||
useGrouping: !!separator,
|
||||
minimumFractionDigits: hasDecimals ? maxDecimals : 0,
|
||||
maximumFractionDigits: hasDecimals ? maxDecimals : 0,
|
||||
};
|
||||
|
||||
const formattedNumber = Intl.NumberFormat("en-US", options).format(latest);
|
||||
|
||||
return separator ? formattedNumber.replace(/,/g, separator) : formattedNumber;
|
||||
},
|
||||
[maxDecimals, separator],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.textContent = formatValue(direction === "down" ? to : from);
|
||||
}
|
||||
}, [from, to, direction, formatValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInView && startWhen) {
|
||||
if (typeof onStart === "function") {
|
||||
onStart();
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
motionValue.set(direction === "down" ? from : to);
|
||||
}, delay * 1000);
|
||||
|
||||
const durationTimeoutId = setTimeout(
|
||||
() => {
|
||||
if (typeof onEnd === "function") {
|
||||
onEnd();
|
||||
}
|
||||
},
|
||||
delay * 1000 + duration * 1000,
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
clearTimeout(durationTimeoutId);
|
||||
};
|
||||
}
|
||||
}, [isInView, startWhen, motionValue, direction, from, to, delay, onStart, onEnd, duration]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = springValue.on("change", (latest: number) => {
|
||||
if (ref.current) {
|
||||
ref.current.textContent = formatValue(latest);
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [springValue, formatValue]);
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={className}
|
||||
aria-hidden={ariaHidden}
|
||||
aria-live={ariaHidden ? undefined : ariaLive}
|
||||
aria-atomic={ariaHidden ? undefined : ariaAtomic}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { motion } from "motion/react";
|
||||
|
||||
type SpotlightProps = {
|
||||
duration?: number;
|
||||
gradientFirst?: string;
|
||||
gradientSecond?: string;
|
||||
gradientThird?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
smallWidth?: number;
|
||||
translateY?: number;
|
||||
xOffset?: number;
|
||||
};
|
||||
|
||||
export const Spotlight = ({
|
||||
duration = 7,
|
||||
gradientFirst = "radial-gradient(68.54% 68.72% at 55.02% 31.46%, hsla(210, 100%, 85%, .08) 0, hsla(210, 100%, 55%, .02) 50%, hsla(210, 100%, 45%, 0) 80%)",
|
||||
gradientSecond = "radial-gradient(50% 50% at 50% 50%, hsla(210, 100%, 85%, .06) 0, hsla(210, 100%, 55%, .02) 80%, transparent 100%)",
|
||||
gradientThird = "radial-gradient(50% 50% at 50% 50%, hsla(210, 100%, 85%, .04) 0, hsla(210, 100%, 45%, .02) 80%, transparent 100%)",
|
||||
width = 560,
|
||||
height = 1380,
|
||||
smallWidth = 240,
|
||||
translateY = -350,
|
||||
xOffset = 100,
|
||||
}: SpotlightProps) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 1.5 }}
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ x: [0, xOffset, 0] }}
|
||||
transition={{ duration, repeat: Infinity, repeatType: "reverse", ease: "easeInOut" }}
|
||||
className="pointer-events-none absolute top-0 left-0 z-40 h-svh w-svw"
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 left-0"
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
background: gradientFirst,
|
||||
transform: `translateY(${translateY}px) rotate(-45deg)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute top-0 left-0 origin-top-left"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: `${smallWidth}px`,
|
||||
background: gradientSecond,
|
||||
transform: "rotate(-45deg) translate(5%, -50%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute top-0 left-0 origin-top-left"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: `${smallWidth}px`,
|
||||
background: gradientThird,
|
||||
transform: "rotate(-45deg) translate(-180%, -70%)",
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
animate={{ x: [0, -xOffset, 0] }}
|
||||
className="pointer-events-none absolute top-0 right-0 z-40 h-svh w-svw"
|
||||
transition={{
|
||||
duration,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 right-0"
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
background: gradientFirst,
|
||||
transform: `translateY(${translateY}px) rotate(45deg)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute top-0 right-0 origin-top-right"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: `${smallWidth}px`,
|
||||
background: gradientSecond,
|
||||
transform: "rotate(45deg) translate(-5%, -50%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute top-0 right-0 origin-top-right"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: `${smallWidth}px`,
|
||||
background: gradientThird,
|
||||
transform: "rotate(45deg) translate(180%, -70%)",
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { motion } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
const textClassName = cn("fill-transparent font-bold text-3xl leading-none tracking-tight");
|
||||
|
||||
type TextMaskEffectProps = {
|
||||
text: string;
|
||||
duration?: number;
|
||||
className?: string;
|
||||
"aria-hidden"?: boolean | "true" | "false";
|
||||
};
|
||||
|
||||
export const TextMaskEffect = ({ text, duration = 6, className, "aria-hidden": ariaHidden }: TextMaskEffectProps) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [cursor, setCursor] = useState({ x: 0, y: 0 });
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [maskPosition, setMaskPosition] = useState({ cx: "50%", cy: "50%" });
|
||||
|
||||
useEffect(() => {
|
||||
if (svgRef.current && cursor.x !== null && cursor.y !== null) {
|
||||
const svgRect = svgRef.current.getBoundingClientRect();
|
||||
const cxPercentage = ((cursor.x - svgRect.left) / svgRect.width) * 100;
|
||||
const cyPercentage = ((cursor.y - svgRect.top) / svgRect.height) * 100;
|
||||
|
||||
setMaskPosition({ cx: `${cxPercentage}%`, cy: `${cyPercentage}%` });
|
||||
}
|
||||
}, [cursor]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 300 40"
|
||||
aria-hidden={ariaHidden}
|
||||
className={cn("select-none", className)}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onMouseMove={(e) => setCursor({ x: e.clientX, y: e.clientY })}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="textGradient" gradientUnits="userSpaceOnUse" cx="50%" cy="50%" r="25%">
|
||||
{hovered && (
|
||||
<>
|
||||
<stop offset="0%" stopColor="#eab308" />
|
||||
<stop offset="25%" stopColor="#ef4444" />
|
||||
<stop offset="50%" stopColor="#3b82f6" />
|
||||
<stop offset="75%" stopColor="#06b6d4" />
|
||||
<stop offset="100%" stopColor="#8b5cf6" />
|
||||
</>
|
||||
)}
|
||||
</linearGradient>
|
||||
|
||||
<motion.radialGradient
|
||||
r="20%"
|
||||
id="revealMask"
|
||||
animate={maskPosition}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
initial={{ cx: "50%", cy: "50%" }}
|
||||
transition={{ duration: 0, ease: "easeOut" }}
|
||||
>
|
||||
<stop offset="0%" stopColor="white" />
|
||||
<stop offset="100%" stopColor="black" />
|
||||
</motion.radialGradient>
|
||||
|
||||
<mask id="textMask">
|
||||
<rect x="0" y="0" width="100%" height="100%" fill="url(#revealMask)" />
|
||||
</mask>
|
||||
</defs>
|
||||
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
strokeWidth="0.3"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
style={{ opacity: hovered ? 0.7 : 0 }}
|
||||
className={cn(textClassName, "stroke-zinc-300 dark:stroke-zinc-700")}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
|
||||
<motion.text
|
||||
x="50%"
|
||||
y="50%"
|
||||
strokeWidth="0.3"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
transition={{ duration, ease: "easeInOut" }}
|
||||
initial={{ strokeDashoffset: 1000, strokeDasharray: 1000 }}
|
||||
whileInView={{ strokeDashoffset: 0, strokeDasharray: 1000 }}
|
||||
className={cn(textClassName, "stroke-zinc-300 dark:stroke-zinc-700")}
|
||||
>
|
||||
{text}
|
||||
</motion.text>
|
||||
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
strokeWidth="0.3"
|
||||
textAnchor="middle"
|
||||
mask="url(#textMask)"
|
||||
dominantBaseline="middle"
|
||||
stroke="url(#textGradient)"
|
||||
className={cn(textClassName)}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useRef } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/animate-ui/components/radix/dialog";
|
||||
import { Command, CommandEmpty, CommandInput, CommandList } from "../ui/command";
|
||||
import { NavigationCommandGroup } from "./pages/navigation";
|
||||
import { PreferencesCommandGroup } from "./pages/preferences";
|
||||
import { ResumesCommandGroup } from "./pages/resumes";
|
||||
import { useCommandPaletteStore } from "./store";
|
||||
|
||||
export function CommandPalette() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { open, search, pages, setOpen, setSearch, goBack } = useCommandPaletteStore();
|
||||
|
||||
const isFirstPage = pages.length === 0;
|
||||
const currentPage = pages[pages.length - 1];
|
||||
|
||||
// Toggle command palette with Cmd+K / Ctrl+K
|
||||
useHotkeys(
|
||||
["meta+k", "ctrl+k"],
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
setOpen(!open);
|
||||
},
|
||||
{ preventDefault: true, enableOnFormTags: true },
|
||||
[open],
|
||||
);
|
||||
|
||||
// Handle backspace: delete text if input has text, go back if empty, close if first page
|
||||
useHotkeys(
|
||||
"backspace",
|
||||
(e) => {
|
||||
// Only handle if the command palette is open
|
||||
if (!open) return;
|
||||
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
|
||||
// Only handle if input is focused
|
||||
if (document.activeElement !== input) return;
|
||||
|
||||
// If input has text, let the default behavior handle it (delete character)
|
||||
if (search.length > 0) return;
|
||||
|
||||
// If input is empty, prevent default and go back
|
||||
e.preventDefault();
|
||||
goBack();
|
||||
},
|
||||
{
|
||||
preventDefault: false, // We'll prevent it conditionally
|
||||
enableOnFormTags: true,
|
||||
},
|
||||
[open, search],
|
||||
);
|
||||
|
||||
// Close with Escape
|
||||
useHotkeys(
|
||||
"escape",
|
||||
() => {
|
||||
if (!open) return;
|
||||
setOpen(false);
|
||||
},
|
||||
{
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true,
|
||||
},
|
||||
[open],
|
||||
);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setOpen(newOpen);
|
||||
};
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearch(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>
|
||||
<Trans>Builder Command Palette</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Type a command or search...</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogContent
|
||||
className="overflow-hidden p-0"
|
||||
aria-label={isFirstPage ? "Command Palette" : `Command Palette - ${currentPage}`}
|
||||
>
|
||||
<Command
|
||||
loop
|
||||
aria-label="Command Palette"
|
||||
className="[&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground **:[[cmdk-group]]:px-2 **:[[cmdk-input]]:h-12 **:[[cmdk-item]]:px-2 **:[[cmdk-item]]:py-3"
|
||||
>
|
||||
<CommandInput
|
||||
ref={inputRef}
|
||||
value={search}
|
||||
onValueChange={handleSearchChange}
|
||||
placeholder={isFirstPage ? "Type a command or search..." : "Search..."}
|
||||
aria-label="Search commands"
|
||||
/>
|
||||
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<Trans>The command you're looking for doesn't exist.</Trans>
|
||||
</CommandEmpty>
|
||||
|
||||
<ResumesCommandGroup />
|
||||
<PreferencesCommandGroup />
|
||||
<NavigationCommandGroup />
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useMemo } from "react";
|
||||
import { CommandGroup } from "@/components/ui/command";
|
||||
import { useCommandPaletteStore } from "../store";
|
||||
|
||||
type Props = {
|
||||
page?: string;
|
||||
heading: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const BaseCommandGroup = ({ page, heading, children }: Props) => {
|
||||
const pages = useCommandPaletteStore((state) => state.pages);
|
||||
const currentPage = pages[pages.length - 1];
|
||||
|
||||
const isEnabled = useMemo(() => {
|
||||
return currentPage === page;
|
||||
}, [currentPage, page]);
|
||||
|
||||
if (!isEnabled) return null;
|
||||
|
||||
return <CommandGroup heading={heading}>{children}</CommandGroup>;
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
GearIcon,
|
||||
HouseSimpleIcon,
|
||||
KeyIcon,
|
||||
OpenAiLogoIcon,
|
||||
ReadCvLogoIcon,
|
||||
ShieldCheckIcon,
|
||||
UserCircleIcon,
|
||||
WarningIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useNavigate, useRouteContext } from "@tanstack/react-router";
|
||||
import { CommandItem } from "@/components/ui/command";
|
||||
import { useCommandPaletteStore } from "../store";
|
||||
import { BaseCommandGroup } from "./base";
|
||||
|
||||
export function NavigationCommandGroup() {
|
||||
const navigate = useNavigate();
|
||||
const { session } = useRouteContext({ strict: false });
|
||||
const reset = useCommandPaletteStore((state) => state.reset);
|
||||
const pushPage = useCommandPaletteStore((state) => state.pushPage);
|
||||
|
||||
function onNavigate(path: string) {
|
||||
navigate({ to: path });
|
||||
reset();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseCommandGroup heading={<Trans>Go to...</Trans>}>
|
||||
<CommandItem keywords={[t`Home`]} value="navigation.home" onSelect={() => onNavigate("/")}>
|
||||
<HouseSimpleIcon />
|
||||
<Trans>Home</Trans>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
disabled={!session}
|
||||
keywords={[t`Resumes`]}
|
||||
value="navigation.resumes"
|
||||
onSelect={() => onNavigate("/dashboard/resumes")}
|
||||
>
|
||||
<ReadCvLogoIcon />
|
||||
<Trans>Resumes</Trans>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
disabled={!session}
|
||||
keywords={[t`Settings`]}
|
||||
value="navigation.settings"
|
||||
onSelect={() => pushPage("settings")}
|
||||
>
|
||||
<GearIcon />
|
||||
<Trans>Settings</Trans>
|
||||
</CommandItem>
|
||||
</BaseCommandGroup>
|
||||
|
||||
<BaseCommandGroup page="settings" heading={<Trans>Settings</Trans>}>
|
||||
<CommandItem
|
||||
keywords={[t`Profile`]}
|
||||
value="navigation.settings.profile"
|
||||
onSelect={() => onNavigate("/dashboard/settings/profile")}
|
||||
>
|
||||
<UserCircleIcon />
|
||||
<Trans>Profile</Trans>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
keywords={[t`Preferences`]}
|
||||
value="navigation.settings.preferences"
|
||||
onSelect={() => onNavigate("/dashboard/settings/preferences")}
|
||||
>
|
||||
<GearIcon />
|
||||
<Trans>Preferences</Trans>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
keywords={[t`Authentication`]}
|
||||
value="navigation.settings.authentication"
|
||||
onSelect={() => onNavigate("/dashboard/settings/authentication")}
|
||||
>
|
||||
<ShieldCheckIcon />
|
||||
<Trans>Authentication</Trans>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
keywords={[t`API Keys`]}
|
||||
value="navigation.settings.api-keys"
|
||||
onSelect={() => onNavigate("/dashboard/settings/api-keys")}
|
||||
>
|
||||
<KeyIcon />
|
||||
<Trans>API Keys</Trans>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
keywords={[t`Artificial Intelligence`]}
|
||||
value="navigation.settings.ai"
|
||||
onSelect={() => onNavigate("/dashboard/settings/ai")}
|
||||
>
|
||||
<OpenAiLogoIcon />
|
||||
<Trans>Artificial Intelligence</Trans>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
keywords={[t`Danger Zone`]}
|
||||
value="navigation.settings.danger-zone"
|
||||
onSelect={() => onNavigate("/dashboard/settings/danger-zone")}
|
||||
>
|
||||
<WarningIcon />
|
||||
<Trans>Danger Zone</Trans>
|
||||
</CommandItem>
|
||||
</BaseCommandGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { PaletteIcon, TranslateIcon } from "@phosphor-icons/react";
|
||||
import { CommandItem } from "@/components/ui/command";
|
||||
import { useCommandPaletteStore } from "../../store";
|
||||
import { BaseCommandGroup } from "../base";
|
||||
import { LanguageCommandPage } from "./language";
|
||||
import { ThemeCommandPage } from "./theme";
|
||||
|
||||
export function PreferencesCommandGroup() {
|
||||
const pushPage = useCommandPaletteStore((state) => state.pushPage);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseCommandGroup heading={<Trans>Preferences</Trans>}>
|
||||
<CommandItem onSelect={() => pushPage("theme")}>
|
||||
<PaletteIcon />
|
||||
<Trans>Change theme to...</Trans>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem onSelect={() => pushPage("language")}>
|
||||
<TranslateIcon />
|
||||
<Trans>Change language to...</Trans>
|
||||
</CommandItem>
|
||||
</BaseCommandGroup>
|
||||
|
||||
<ThemeCommandPage />
|
||||
<LanguageCommandPage />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { CommandItem } from "@/components/ui/command";
|
||||
import { isLocale, loadLocale, localeMap, setLocaleServerFn } from "@/utils/locale";
|
||||
import { BaseCommandGroup } from "../base";
|
||||
|
||||
export function LanguageCommandPage() {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const handleLocaleChange = async (value: string) => {
|
||||
if (!value || !isLocale(value)) return;
|
||||
await Promise.all([loadLocale(value), setLocaleServerFn({ data: value })]);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseCommandGroup page="language" heading={<Trans>Language</Trans>}>
|
||||
{Object.entries(localeMap).map(([value, label]) => (
|
||||
<CommandItem key={value} onSelect={() => handleLocaleChange(value)}>
|
||||
<span className="font-mono text-muted-foreground text-xs">{value}</span>
|
||||
{i18n.t(label)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</BaseCommandGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { MoonIcon, SunIcon } from "@phosphor-icons/react";
|
||||
import { useTheme } from "@/components/theme/provider";
|
||||
import { CommandItem } from "@/components/ui/command";
|
||||
import { useCommandPaletteStore } from "../../store";
|
||||
import { BaseCommandGroup } from "../base";
|
||||
|
||||
export function ThemeCommandPage() {
|
||||
const { setTheme } = useTheme();
|
||||
const setOpen = useCommandPaletteStore((state) => state.setOpen);
|
||||
|
||||
const handleThemeChange = (theme: "light" | "dark") => {
|
||||
setTheme(theme, { playSound: false });
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseCommandGroup page="theme" heading={<Trans>Theme</Trans>}>
|
||||
<CommandItem value="light" onSelect={() => handleThemeChange("light")}>
|
||||
<SunIcon />
|
||||
<Trans>Light theme</Trans>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem value="dark" onSelect={() => handleThemeChange("dark")}>
|
||||
<MoonIcon />
|
||||
<Trans>Dark theme</Trans>
|
||||
</CommandItem>
|
||||
</BaseCommandGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { PlusIcon, ReadCvLogoIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useRouteContext } from "@tanstack/react-router";
|
||||
import { CommandLoading } from "cmdk";
|
||||
import { CommandItem, CommandShortcut } from "@/components/ui/command";
|
||||
import { Kbd } from "@/components/ui/kbd";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { useCommandPaletteStore } from "../store";
|
||||
import { BaseCommandGroup } from "./base";
|
||||
|
||||
export function ResumesCommandGroup() {
|
||||
const navigate = useNavigate();
|
||||
const { openDialog } = useDialogStore();
|
||||
const { session } = useRouteContext({ strict: false });
|
||||
const reset = useCommandPaletteStore((state) => state.reset);
|
||||
const peekPage = useCommandPaletteStore((state) => state.peekPage);
|
||||
const pushPage = useCommandPaletteStore((state) => state.pushPage);
|
||||
|
||||
const isResumesPage = peekPage() === "resumes";
|
||||
|
||||
const { data: resumes, isLoading } = useQuery(
|
||||
orpc.resume.list.queryOptions({
|
||||
enabled: !!session && isResumesPage,
|
||||
}),
|
||||
);
|
||||
|
||||
const onCreate = () => {
|
||||
navigate({ to: "/dashboard/resumes" });
|
||||
openDialog("resume.create", undefined);
|
||||
reset();
|
||||
};
|
||||
|
||||
const onNavigate = (path: string) => {
|
||||
navigate({ to: path });
|
||||
reset();
|
||||
};
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseCommandGroup heading={<Trans>Search for...</Trans>}>
|
||||
<CommandItem keywords={[t`Resumes`]} value="search.resumes" onSelect={() => pushPage("resumes")}>
|
||||
<ReadCvLogoIcon />
|
||||
<Trans>Resumes</Trans>
|
||||
</CommandItem>
|
||||
</BaseCommandGroup>
|
||||
|
||||
<BaseCommandGroup page="resumes" heading={<Trans>Resumes</Trans>}>
|
||||
<CommandItem onSelect={onCreate}>
|
||||
<PlusIcon />
|
||||
<Trans>Create a new resume</Trans>
|
||||
</CommandItem>
|
||||
|
||||
{isLoading ? (
|
||||
<CommandLoading>
|
||||
<Trans>Loading resumes...</Trans>
|
||||
</CommandLoading>
|
||||
) : (
|
||||
resumes?.map((resume) => (
|
||||
<CommandItem
|
||||
key={resume.id}
|
||||
value={resume.id}
|
||||
keywords={[resume.name]}
|
||||
onSelect={() => onNavigate(`/builder/${resume.id}`)}
|
||||
>
|
||||
<ReadCvLogoIcon />
|
||||
{resume.name}
|
||||
|
||||
<CommandShortcut className="opacity-0 transition-opacity group-data-[selected=true]/command-item:opacity-100">
|
||||
Press <Kbd>Enter</Kbd> to open
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
))
|
||||
)}
|
||||
</BaseCommandGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { create } from "zustand/react";
|
||||
|
||||
type CommandPaletteState = {
|
||||
open: boolean;
|
||||
search: string;
|
||||
pages: string[];
|
||||
};
|
||||
|
||||
type CommandPaletteActions = {
|
||||
setOpen: (open: boolean) => void;
|
||||
setSearch: (search: string) => void;
|
||||
pushPage: (page: string) => void;
|
||||
peekPage: () => string | undefined;
|
||||
popPage: () => void;
|
||||
reset: () => void;
|
||||
goBack: () => void;
|
||||
};
|
||||
|
||||
type CommandPaletteStore = CommandPaletteState & CommandPaletteActions;
|
||||
|
||||
const initialState: CommandPaletteState = {
|
||||
open: false,
|
||||
search: "",
|
||||
pages: [],
|
||||
};
|
||||
|
||||
export const useCommandPaletteStore = create<CommandPaletteStore>((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
setOpen: (open) => {
|
||||
set({ open });
|
||||
if (!open) set(initialState);
|
||||
},
|
||||
|
||||
setSearch: (search) => set({ search }),
|
||||
|
||||
peekPage: () => get().pages[get().pages.length - 1],
|
||||
|
||||
pushPage: (page) => set((state) => ({ pages: [...state.pages, page], search: "" })),
|
||||
|
||||
popPage: () => set((state) => ({ pages: state.pages.slice(0, -1), search: "" })),
|
||||
|
||||
reset: () => set(initialState),
|
||||
|
||||
goBack: () => {
|
||||
set((state) => {
|
||||
if (state.search.length > 0) {
|
||||
// If there's text, just clear the search
|
||||
return { search: "" };
|
||||
}
|
||||
if (state.pages.length > 0) {
|
||||
// If on a sub-page, go back
|
||||
return { pages: state.pages.slice(0, -1), search: "" };
|
||||
}
|
||||
// If on first page, close
|
||||
return { open: false, search: "", pages: [] };
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,123 @@
|
||||
import { XIcon } from "@phosphor-icons/react";
|
||||
import * as React from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type Props = Omit<React.ComponentProps<"div">, "value" | "onChange"> & {
|
||||
value?: string[];
|
||||
defaultValue?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
};
|
||||
|
||||
export function ChipInput({ value, defaultValue = [], onChange, className, ...props }: Props) {
|
||||
const [chips, setChips] = useControlledState<string[]>({
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
});
|
||||
|
||||
const [input, setInput] = React.useState("");
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const addChip = React.useCallback(
|
||||
(chip: string) => {
|
||||
const trimmed = chip.trim();
|
||||
if (!trimmed) return;
|
||||
const newChips = Array.from(new Set([...chips, trimmed]));
|
||||
setChips(newChips);
|
||||
},
|
||||
[chips, setChips],
|
||||
);
|
||||
|
||||
const removeChip = React.useCallback(
|
||||
(index: number) => {
|
||||
if (index < 0 || index >= chips.length) return;
|
||||
const newChips = chips.slice(0, index).concat(chips.slice(index + 1));
|
||||
setChips(Array.from(new Set(newChips)));
|
||||
},
|
||||
[chips, setChips],
|
||||
);
|
||||
|
||||
const handleInputChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
if (newValue.includes(",")) {
|
||||
const parts = newValue.split(",");
|
||||
parts.slice(0, -1).forEach(addChip);
|
||||
setInput(parts[parts.length - 1]);
|
||||
} else {
|
||||
setInput(newValue);
|
||||
}
|
||||
},
|
||||
[addChip],
|
||||
);
|
||||
|
||||
const removeLastChip = React.useCallback(() => {
|
||||
if (chips.length === 0) return;
|
||||
const newChips = chips.slice(0, -1);
|
||||
setChips(newChips);
|
||||
}, [chips, setChips]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if ((e.key === "Enter" || e.key === ",") && input.trim() !== "") {
|
||||
e.preventDefault();
|
||||
addChip(input);
|
||||
setInput("");
|
||||
} else if (e.key === "Backspace" && input === "") {
|
||||
removeLastChip();
|
||||
}
|
||||
},
|
||||
[input, addChip, removeLastChip],
|
||||
);
|
||||
|
||||
const handleWrapperClick = React.useCallback(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={-1}
|
||||
onClick={handleWrapperClick}
|
||||
className={cn(
|
||||
"flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border bg-background px-3 py-1.5 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{chips.map((chip, idx) => (
|
||||
<div key={chip + idx} className="relative">
|
||||
<Badge variant="outline" className="flex select-none items-center gap-1 pr-1 pl-2">
|
||||
<span>{chip}</span>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={`Remove ${chip}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeChip(idx);
|
||||
}}
|
||||
className="ml-0.5 hover:text-destructive focus:outline-none"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
ref={inputRef}
|
||||
autoComplete="off"
|
||||
aria-label="Add chip"
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleInputChange}
|
||||
className="min-w-0 grow border-none bg-transparent outline-none focus:outline-none focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,831 @@
|
||||
import { EyedropperIcon } from "@phosphor-icons/react";
|
||||
import { Slider as SliderPrimitive, Slot as SlotPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { VisuallyHiddenInput } from "@/components/input/visually-hidden-input";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
type ColorValue,
|
||||
type HSVColorValue,
|
||||
hexToRgb,
|
||||
hsvToRgb,
|
||||
parseRgbString,
|
||||
rgbToHsv,
|
||||
rgbToString,
|
||||
} from "@/utils/color";
|
||||
import { useComposedRefs } from "@/utils/compose-refs";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
interface EyeDropper {
|
||||
open: (options?: { signal?: AbortSignal }) => Promise<{ sRGBHex: string }>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
EyeDropper?: {
|
||||
new (): EyeDropper;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type Direction = "ltr" | "rtl";
|
||||
|
||||
const DirectionContext = React.createContext<Direction | undefined>(undefined);
|
||||
|
||||
function useDirection(dirProp?: Direction): Direction {
|
||||
const contextDir = React.useContext(DirectionContext);
|
||||
return dirProp ?? contextDir ?? "ltr";
|
||||
}
|
||||
|
||||
function useLazyRef<T>(fn: () => T) {
|
||||
const ref = React.useRef<T | null>(null);
|
||||
|
||||
if (ref.current === null) {
|
||||
ref.current = fn();
|
||||
}
|
||||
|
||||
return ref as React.RefObject<T>;
|
||||
}
|
||||
|
||||
interface ColorPickerStoreState {
|
||||
color: ColorValue;
|
||||
hsv: HSVColorValue;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
interface ColorPickerStoreCallbacks {
|
||||
onColorChange?: (colorString: string) => void;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function colorValuesEqual(a: ColorValue, b: ColorValue): boolean {
|
||||
return a.r === b.r && a.g === b.g && a.b === b.b && a.a === b.a;
|
||||
}
|
||||
|
||||
function hsvValuesEqual(a: HSVColorValue, b: HSVColorValue): boolean {
|
||||
return a.h === b.h && a.s === b.s && a.v === b.v && a.a === b.a;
|
||||
}
|
||||
|
||||
interface ColorPickerStore {
|
||||
subscribe: (cb: () => void) => () => void;
|
||||
getState: () => ColorPickerStoreState;
|
||||
setColor: (value: ColorValue) => void;
|
||||
setHsv: (value: HSVColorValue) => void;
|
||||
setOpen: (value: boolean) => void;
|
||||
syncColor: (value: ColorValue) => void;
|
||||
notify: () => void;
|
||||
}
|
||||
|
||||
function createColorPickerStore(
|
||||
listenersRef: React.RefObject<Set<() => void>>,
|
||||
stateRef: React.RefObject<ColorPickerStoreState>,
|
||||
callbacks?: ColorPickerStoreCallbacks,
|
||||
): ColorPickerStore {
|
||||
const store: ColorPickerStore = {
|
||||
subscribe: (cb) => {
|
||||
if (listenersRef.current) {
|
||||
listenersRef.current.add(cb);
|
||||
return () => listenersRef.current?.delete(cb);
|
||||
}
|
||||
return () => {};
|
||||
},
|
||||
getState: () =>
|
||||
stateRef.current || {
|
||||
color: { r: 0, g: 0, b: 0, a: 1 },
|
||||
hsv: { h: 0, s: 0, v: 0, a: 1 },
|
||||
open: false,
|
||||
},
|
||||
setColor: (value: ColorValue) => {
|
||||
if (!stateRef.current) return;
|
||||
if (colorValuesEqual(stateRef.current.color, value)) return;
|
||||
|
||||
stateRef.current.color = value;
|
||||
stateRef.current.hsv = rgbToHsv(value);
|
||||
|
||||
if (callbacks?.onColorChange) {
|
||||
const colorString = rgbToString(value);
|
||||
callbacks.onColorChange(colorString);
|
||||
}
|
||||
|
||||
store.notify();
|
||||
},
|
||||
setHsv: (value: HSVColorValue) => {
|
||||
if (!stateRef.current) return;
|
||||
if (hsvValuesEqual(stateRef.current.hsv, value)) return;
|
||||
|
||||
stateRef.current.hsv = value;
|
||||
stateRef.current.color = hsvToRgb(value);
|
||||
|
||||
if (callbacks?.onColorChange) {
|
||||
const colorString = rgbToString(stateRef.current.color);
|
||||
callbacks.onColorChange(colorString);
|
||||
}
|
||||
|
||||
store.notify();
|
||||
},
|
||||
syncColor: (value: ColorValue) => {
|
||||
if (!stateRef.current) return;
|
||||
if (colorValuesEqual(stateRef.current.color, value)) return;
|
||||
|
||||
stateRef.current.color = value;
|
||||
stateRef.current.hsv = rgbToHsv(value);
|
||||
store.notify();
|
||||
},
|
||||
setOpen: (value: boolean) => {
|
||||
if (!stateRef.current) return;
|
||||
if (Object.is(stateRef.current.open, value)) return;
|
||||
|
||||
stateRef.current.open = value;
|
||||
|
||||
if (callbacks?.onOpenChange) {
|
||||
callbacks.onOpenChange(value);
|
||||
}
|
||||
|
||||
store.notify();
|
||||
},
|
||||
notify: () => {
|
||||
if (listenersRef.current) {
|
||||
for (const cb of listenersRef.current) {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
function useColorPickerStoreContext(consumerName: string) {
|
||||
const context = React.useContext(ColorPickerStoreContext);
|
||||
if (!context) {
|
||||
throw new Error(`\`${consumerName}\` must be used within \`ColorPickerRoot\``);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
function useColorPickerStore<U>(selector: (state: ColorPickerStoreState) => U): U {
|
||||
const store = useColorPickerStoreContext("useColorPickerStoreSelector");
|
||||
|
||||
const getSnapshot = React.useCallback(() => selector(store.getState()), [store, selector]);
|
||||
|
||||
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
||||
interface ColorPickerContextValue {
|
||||
dir: Direction;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
const ColorPickerStoreContext = React.createContext<ColorPickerStore | null>(null);
|
||||
const ColorPickerContext = React.createContext<ColorPickerContextValue | null>(null);
|
||||
|
||||
function useColorPickerContext(consumerName: string) {
|
||||
const context = React.useContext(ColorPickerContext);
|
||||
if (!context) {
|
||||
throw new Error(`\`${consumerName}\` must be used within \`ColorPickerRoot\``);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface ColorPickerRootProps
|
||||
extends Omit<React.ComponentProps<"div">, "onValueChange">,
|
||||
Pick<React.ComponentProps<typeof Popover>, "defaultOpen" | "open" | "onOpenChange" | "modal"> {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
dir?: Direction;
|
||||
name?: string;
|
||||
asChild?: boolean;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
function ColorPickerRoot(props: ColorPickerRootProps) {
|
||||
const {
|
||||
value: valueProp,
|
||||
defaultValue = "rgb(0, 0, 0)",
|
||||
onValueChange,
|
||||
defaultOpen,
|
||||
open: openProp,
|
||||
onOpenChange,
|
||||
name,
|
||||
disabled,
|
||||
readOnly,
|
||||
required,
|
||||
...rootProps
|
||||
} = props;
|
||||
|
||||
const initialColor = React.useMemo(() => {
|
||||
const colorString = valueProp ?? defaultValue;
|
||||
const parsed = parseRgbString(colorString);
|
||||
const color = parsed ?? { r: 0, g: 0, b: 0, a: 1 };
|
||||
|
||||
return {
|
||||
color,
|
||||
hsv: rgbToHsv(color),
|
||||
open: openProp ?? defaultOpen ?? false,
|
||||
};
|
||||
}, [valueProp, defaultValue, openProp, defaultOpen]);
|
||||
|
||||
const stateRef = useLazyRef(() => initialColor);
|
||||
const listenersRef = useLazyRef(() => new Set<() => void>());
|
||||
|
||||
const storeCallbacks = React.useMemo<ColorPickerStoreCallbacks>(
|
||||
() => ({
|
||||
onColorChange: onValueChange,
|
||||
onOpenChange: onOpenChange,
|
||||
}),
|
||||
[onValueChange, onOpenChange],
|
||||
);
|
||||
|
||||
const store = React.useMemo(
|
||||
() => createColorPickerStore(listenersRef, stateRef, storeCallbacks),
|
||||
[listenersRef, stateRef, storeCallbacks],
|
||||
);
|
||||
|
||||
return (
|
||||
<ColorPickerStoreContext.Provider value={store}>
|
||||
<ColorPickerRootImpl
|
||||
{...rootProps}
|
||||
value={valueProp}
|
||||
defaultOpen={defaultOpen}
|
||||
open={openProp}
|
||||
onOpenChange={onOpenChange}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
required={required}
|
||||
/>
|
||||
</ColorPickerStoreContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface ColorPickerRootImplProps extends Omit<ColorPickerRootProps, "defaultValue" | "onValueChange"> {}
|
||||
|
||||
function ColorPickerRootImpl(props: ColorPickerRootImplProps) {
|
||||
const {
|
||||
value: valueProp,
|
||||
dir: dirProp,
|
||||
defaultOpen,
|
||||
open: openProp,
|
||||
onOpenChange,
|
||||
name,
|
||||
ref,
|
||||
asChild,
|
||||
disabled,
|
||||
modal,
|
||||
readOnly,
|
||||
required,
|
||||
...rootProps
|
||||
} = props;
|
||||
|
||||
const store = useColorPickerStoreContext("ColorPickerRootImpl");
|
||||
|
||||
const dir = useDirection(dirProp);
|
||||
|
||||
const [formTrigger, setFormTrigger] = React.useState<HTMLDivElement | null>(null);
|
||||
const composedRef = useComposedRefs(ref, (node) => setFormTrigger(node));
|
||||
|
||||
const isFormControl = formTrigger ? !!formTrigger.closest("form") : true;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (valueProp !== undefined) {
|
||||
const parsed = parseRgbString(valueProp);
|
||||
if (parsed) {
|
||||
const currentState = store.getState();
|
||||
const color = { ...parsed, a: parsed.a ?? currentState.color.a };
|
||||
store.syncColor(color);
|
||||
}
|
||||
}
|
||||
}, [valueProp, store]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (openProp !== undefined) {
|
||||
store.setOpen(openProp);
|
||||
}
|
||||
}, [openProp, store]);
|
||||
|
||||
const contextValue = React.useMemo<ColorPickerContextValue>(
|
||||
() => ({
|
||||
dir,
|
||||
disabled,
|
||||
readOnly,
|
||||
required,
|
||||
}),
|
||||
[dir, disabled, readOnly, required],
|
||||
);
|
||||
|
||||
const value = useColorPickerStore((state) => rgbToString(state.color));
|
||||
|
||||
const open = useColorPickerStore((state) => state.open);
|
||||
|
||||
const onPopoverOpenChange = React.useCallback(
|
||||
(newOpen: boolean) => {
|
||||
store.setOpen(newOpen);
|
||||
onOpenChange?.(newOpen);
|
||||
},
|
||||
[store, onOpenChange],
|
||||
);
|
||||
|
||||
const RootPrimitive = asChild ? SlotPrimitive.Slot : "div";
|
||||
|
||||
return (
|
||||
<ColorPickerContext.Provider value={contextValue}>
|
||||
<Popover defaultOpen={defaultOpen} open={open} onOpenChange={onPopoverOpenChange} modal={modal}>
|
||||
<RootPrimitive {...rootProps} ref={composedRef} />
|
||||
{isFormControl && (
|
||||
<VisuallyHiddenInput
|
||||
type="hidden"
|
||||
name={name}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
required={required}
|
||||
control={formTrigger}
|
||||
/>
|
||||
)}
|
||||
</Popover>
|
||||
</ColorPickerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface ColorPickerTriggerProps extends React.ComponentProps<typeof PopoverTrigger> {}
|
||||
|
||||
function ColorPickerTrigger(props: ColorPickerTriggerProps) {
|
||||
const { asChild, ...triggerProps } = props;
|
||||
const context = useColorPickerContext("ColorPickerTrigger");
|
||||
|
||||
const TriggerPrimitive = asChild ? SlotPrimitive.Slot : PopoverTrigger;
|
||||
|
||||
return (
|
||||
<PopoverTrigger asChild disabled={context.disabled}>
|
||||
<TriggerPrimitive data-slot="color-picker-trigger" {...triggerProps} />
|
||||
</PopoverTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
interface ColorPickerContentProps extends React.ComponentProps<typeof PopoverContent> {}
|
||||
|
||||
function ColorPickerContent(props: ColorPickerContentProps) {
|
||||
const { asChild, className, children, ...popoverContentProps } = props;
|
||||
|
||||
return (
|
||||
<PopoverContent
|
||||
data-slot="color-picker-content"
|
||||
asChild={asChild}
|
||||
{...popoverContentProps}
|
||||
className={cn("flex w-[340px] flex-col gap-4 p-4", className)}
|
||||
>
|
||||
{children}
|
||||
</PopoverContent>
|
||||
);
|
||||
}
|
||||
|
||||
interface ColorPickerSwatchProps extends React.ComponentProps<"div"> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function ColorPickerSwatch(props: ColorPickerSwatchProps) {
|
||||
const { asChild, className, ...swatchProps } = props;
|
||||
const context = useColorPickerContext("ColorPickerSwatch");
|
||||
|
||||
const color = useColorPickerStore((state) => state.color);
|
||||
|
||||
const backgroundStyle = React.useMemo(() => {
|
||||
if (!color) {
|
||||
return {
|
||||
background:
|
||||
"linear-gradient(to bottom right, transparent calc(50% - 1px), hsl(var(--destructive)) calc(50% - 1px) calc(50% + 1px), transparent calc(50% + 1px)) no-repeat",
|
||||
};
|
||||
}
|
||||
|
||||
const colorString = `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`;
|
||||
|
||||
if (color.a < 1) {
|
||||
return {
|
||||
background: `linear-gradient(${colorString}, ${colorString}), repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 0% 50% / 8px 8px`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: colorString,
|
||||
};
|
||||
}, [color]);
|
||||
|
||||
const ariaLabel = !color ? "No color selected" : `Current color: ${rgbToString(color)}`;
|
||||
|
||||
const SwatchPrimitive = asChild ? SlotPrimitive.Slot : "div";
|
||||
|
||||
return (
|
||||
<div className="flex size-9 items-center justify-start">
|
||||
<SwatchPrimitive
|
||||
{...swatchProps}
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
data-slot="color-picker-swatch"
|
||||
className={cn("box-border size-7 rounded-md border", context.disabled && "opacity-50", className)}
|
||||
style={{
|
||||
...backgroundStyle,
|
||||
forcedColorAdjust: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ColorPickerAreaProps extends React.ComponentProps<"div"> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function ColorPickerArea(props: ColorPickerAreaProps) {
|
||||
const { asChild, className, ref, ...areaProps } = props;
|
||||
const context = useColorPickerContext("ColorPickerArea");
|
||||
const store = useColorPickerStoreContext("ColorPickerArea");
|
||||
|
||||
const hsv = useColorPickerStore((state) => state.hsv);
|
||||
|
||||
const isDraggingRef = React.useRef(false);
|
||||
const areaRef = React.useRef<HTMLDivElement>(null);
|
||||
const composedRef = useComposedRefs(ref, areaRef);
|
||||
|
||||
const updateColorFromPosition = React.useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
if (!areaRef.current) return;
|
||||
|
||||
const rect = areaRef.current.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
const y = Math.max(0, Math.min(1, 1 - (clientY - rect.top) / rect.height));
|
||||
|
||||
const newHsv: HSVColorValue = {
|
||||
h: hsv?.h ?? 0,
|
||||
s: Math.round(x * 100),
|
||||
v: Math.round(y * 100),
|
||||
a: hsv?.a ?? 1,
|
||||
};
|
||||
|
||||
store.setHsv(newHsv);
|
||||
},
|
||||
[hsv, store],
|
||||
);
|
||||
|
||||
const onPointerDown = React.useCallback(
|
||||
(event: React.PointerEvent) => {
|
||||
if (context.disabled) return;
|
||||
|
||||
isDraggingRef.current = true;
|
||||
areaRef.current?.setPointerCapture(event.pointerId);
|
||||
updateColorFromPosition(event.clientX, event.clientY);
|
||||
},
|
||||
[context.disabled, updateColorFromPosition],
|
||||
);
|
||||
|
||||
const onPointerMove = React.useCallback(
|
||||
(event: React.PointerEvent) => {
|
||||
if (isDraggingRef.current) {
|
||||
updateColorFromPosition(event.clientX, event.clientY);
|
||||
}
|
||||
},
|
||||
[updateColorFromPosition],
|
||||
);
|
||||
|
||||
const onPointerUp = React.useCallback((event: React.PointerEvent) => {
|
||||
isDraggingRef.current = false;
|
||||
areaRef.current?.releasePointerCapture(event.pointerId);
|
||||
}, []);
|
||||
|
||||
const hue = hsv?.h ?? 0;
|
||||
const backgroundHue = hsvToRgb({ h: hue, s: 100, v: 100, a: 1 });
|
||||
|
||||
const AreaPrimitive = asChild ? SlotPrimitive.Slot : "div";
|
||||
|
||||
return (
|
||||
<AreaPrimitive
|
||||
data-slot="color-picker-area"
|
||||
{...areaProps}
|
||||
className={cn(
|
||||
"relative h-40 w-full cursor-crosshair touch-none rounded-md border",
|
||||
context.disabled && "pointer-events-none opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={composedRef}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<div className="absolute inset-0 overflow-hidden rounded-md">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundColor: `rgb(${backgroundHue.r}, ${backgroundHue.g}, ${backgroundHue.b})`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: "linear-gradient(to right, #fff, transparent)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: "linear-gradient(to bottom, transparent, #000)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white"
|
||||
style={{
|
||||
left: `${hsv?.s ?? 0}%`,
|
||||
top: `${100 - (hsv?.v ?? 0)}%`,
|
||||
}}
|
||||
/>
|
||||
</AreaPrimitive>
|
||||
);
|
||||
}
|
||||
|
||||
interface ColorPickerHueSliderProps extends React.ComponentProps<typeof SliderPrimitive.Root> {}
|
||||
|
||||
function ColorPickerHueSlider(props: ColorPickerHueSliderProps) {
|
||||
const { className, ...sliderProps } = props;
|
||||
const context = useColorPickerContext("ColorPickerHueSlider");
|
||||
const store = useColorPickerStoreContext("ColorPickerHueSlider");
|
||||
|
||||
const hsv = useColorPickerStore((state) => state.hsv);
|
||||
|
||||
const onValueChange = React.useCallback(
|
||||
(values: number[]) => {
|
||||
const newHsv: HSVColorValue = {
|
||||
h: values[0] ?? 0,
|
||||
s: hsv?.s ?? 0,
|
||||
v: hsv?.v ?? 0,
|
||||
a: hsv?.a ?? 1,
|
||||
};
|
||||
store.setHsv(newHsv);
|
||||
},
|
||||
[hsv, store],
|
||||
);
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="color-picker-hue-slider"
|
||||
{...sliderProps}
|
||||
max={360}
|
||||
step={1}
|
||||
className={cn("relative flex w-full touch-none select-none items-center", className)}
|
||||
value={[hsv?.h ?? 0]}
|
||||
onValueChange={onValueChange}
|
||||
disabled={context.disabled}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-3 w-full grow overflow-hidden rounded-full bg-[linear-gradient(to_right,#ff0000_0%,#ffff00_16.66%,#00ff00_33.33%,#00ffff_50%,#0000ff_66.66%,#ff00ff_83.33%,#ff0000_100%)]">
|
||||
<SliderPrimitive.Range className="absolute h-full" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block size-4 rounded-full border border-primary/50 bg-background transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
type ColorPickerEyeDropperProps = React.ComponentProps<typeof Button>;
|
||||
|
||||
function ColorPickerEyeDropper(props: ColorPickerEyeDropperProps) {
|
||||
const { children, size, ...buttonProps } = props;
|
||||
const context = useColorPickerContext("ColorPickerEyeDropper");
|
||||
const store = useColorPickerStoreContext("ColorPickerEyeDropper");
|
||||
|
||||
const color = useColorPickerStore((state) => state.color);
|
||||
|
||||
const onEyeDropper = React.useCallback(async () => {
|
||||
if (!window.EyeDropper) return;
|
||||
|
||||
try {
|
||||
const eyeDropper = new window.EyeDropper();
|
||||
const result = await eyeDropper.open();
|
||||
|
||||
if (result.sRGBHex) {
|
||||
const currentAlpha = color?.a ?? 1;
|
||||
const newColor = hexToRgb(result.sRGBHex, currentAlpha);
|
||||
store.setColor(newColor);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("EyeDropper error:", error);
|
||||
}
|
||||
}, [color, store]);
|
||||
|
||||
const hasEyeDropper = typeof window !== "undefined" && !!window.EyeDropper;
|
||||
|
||||
if (!hasEyeDropper) return null;
|
||||
|
||||
const buttonSize = size ?? (children ? "default" : "icon");
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="color-picker-eye-dropper"
|
||||
{...buttonProps}
|
||||
variant="outline"
|
||||
size={buttonSize}
|
||||
onClick={onEyeDropper}
|
||||
disabled={context.disabled}
|
||||
>
|
||||
{children ?? <EyedropperIcon />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface ColorPickerAlphaSliderProps extends React.ComponentProps<typeof SliderPrimitive.Root> {}
|
||||
|
||||
function ColorPickerAlphaSlider(props: ColorPickerAlphaSliderProps) {
|
||||
const { className, ...sliderProps } = props;
|
||||
const context = useColorPickerContext("ColorPickerAlphaSlider");
|
||||
const store = useColorPickerStoreContext("ColorPickerAlphaSlider");
|
||||
|
||||
const color = useColorPickerStore((state) => state.color);
|
||||
|
||||
const onValueChange = React.useCallback(
|
||||
(values: number[]) => {
|
||||
const alpha = (values[0] ?? 0) / 100;
|
||||
const newColor = { ...color, a: alpha };
|
||||
store.setColor(newColor);
|
||||
},
|
||||
[color, store],
|
||||
);
|
||||
|
||||
const gradientColor = `rgb(${color?.r ?? 0}, ${color?.g ?? 0}, ${color?.b ?? 0})`;
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="color-picker-alpha-slider"
|
||||
{...sliderProps}
|
||||
max={100}
|
||||
step={1}
|
||||
disabled={context.disabled}
|
||||
className={cn("relative flex w-full touch-none select-none items-center", className)}
|
||||
value={[Math.round((color?.a ?? 1) * 100)]}
|
||||
onValueChange={onValueChange}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
className="relative h-3 w-full grow overflow-hidden rounded-full"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(-45deg, transparent 75%, #ccc 75%)",
|
||||
backgroundSize: "8px 8px",
|
||||
backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{
|
||||
background: `linear-gradient(to right, transparent, ${gradientColor})`,
|
||||
}}
|
||||
/>
|
||||
<SliderPrimitive.Range className="absolute h-full" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block size-4 rounded-full border border-primary/50 bg-background transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
interface ColorPickerInputProps extends Omit<React.ComponentProps<typeof Input>, "value" | "onChange"> {
|
||||
withoutAlpha?: boolean;
|
||||
}
|
||||
|
||||
function ColorPickerInput(props: ColorPickerInputProps) {
|
||||
const context = useColorPickerContext("ColorPickerInput");
|
||||
const store = useColorPickerStoreContext("ColorPickerInput");
|
||||
|
||||
const color = useColorPickerStore((state) => state.color);
|
||||
|
||||
const onChannelChange = React.useCallback(
|
||||
(channel: "r" | "g" | "b" | "a", max: number, isAlpha = false) =>
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = Number.parseInt(event.target.value, 10);
|
||||
if (!Number.isNaN(value) && value >= 0 && value <= max) {
|
||||
const newValue = isAlpha ? value / 100 : value;
|
||||
const newColor = { ...color, [channel]: newValue };
|
||||
store.setColor(newColor);
|
||||
}
|
||||
},
|
||||
[color, store],
|
||||
);
|
||||
|
||||
const rValue = Math.round(color?.r ?? 0);
|
||||
const gValue = Math.round(color?.g ?? 0);
|
||||
const bValue = Math.round(color?.b ?? 0);
|
||||
const alphaValue = Math.round((color?.a ?? 1) * 100);
|
||||
|
||||
return (
|
||||
<div data-slot="color-picker-input-wrapper" className="flex items-center gap-2">
|
||||
<span>R</span>
|
||||
<Input
|
||||
type="number"
|
||||
aria-label="Red color component (0-255)"
|
||||
placeholder="0"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
min="0"
|
||||
max="255"
|
||||
className="flex-1 shrink-0"
|
||||
value={rValue}
|
||||
onChange={onChannelChange("r", 255)}
|
||||
disabled={context.disabled}
|
||||
/>
|
||||
<span>G</span>
|
||||
<Input
|
||||
type="number"
|
||||
aria-label="Green color component (0-255)"
|
||||
placeholder="0"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
min="0"
|
||||
max="255"
|
||||
className="flex-1 shrink-0"
|
||||
value={gValue}
|
||||
onChange={onChannelChange("g", 255)}
|
||||
disabled={context.disabled}
|
||||
/>
|
||||
<span>B</span>
|
||||
<Input
|
||||
type="number"
|
||||
aria-label="Blue color component (0-255)"
|
||||
placeholder="0"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
min="0"
|
||||
max="255"
|
||||
className="flex-1 shrink-0"
|
||||
value={bValue}
|
||||
onChange={onChannelChange("b", 255)}
|
||||
disabled={context.disabled}
|
||||
/>
|
||||
{!props.withoutAlpha && (
|
||||
<>
|
||||
<span>A</span>
|
||||
<Input
|
||||
type="number"
|
||||
aria-label="Alpha transparency percentage"
|
||||
placeholder="100"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
min="0"
|
||||
max="100"
|
||||
className="flex-1 shrink-0"
|
||||
value={alphaValue}
|
||||
onChange={onChannelChange("a", 100, true)}
|
||||
disabled={context.disabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ColorPickerProps = Omit<React.ComponentProps<typeof ColorPickerRoot>, never>;
|
||||
|
||||
const ColorPicker = (props: ColorPickerProps) => {
|
||||
return (
|
||||
<ColorPickerRoot {...props}>
|
||||
<ColorPickerTrigger asChild>
|
||||
<ColorPickerSwatch />
|
||||
</ColorPickerTrigger>
|
||||
<ColorPickerContent>
|
||||
<ColorPickerArea />
|
||||
<div className="flex items-center gap-4">
|
||||
<ColorPickerEyeDropper />
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<ColorPickerHueSlider />
|
||||
<ColorPickerAlphaSlider />
|
||||
</div>
|
||||
</div>
|
||||
<ColorPickerInput />
|
||||
</ColorPickerContent>
|
||||
</ColorPickerRoot>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
ColorPicker,
|
||||
//
|
||||
ColorPickerRoot,
|
||||
ColorPickerTrigger,
|
||||
ColorPickerContent,
|
||||
ColorPickerArea,
|
||||
ColorPickerHueSlider,
|
||||
ColorPickerAlphaSlider,
|
||||
ColorPickerSwatch,
|
||||
ColorPickerEyeDropper,
|
||||
ColorPickerInput,
|
||||
//
|
||||
ColorPickerRoot as Root,
|
||||
ColorPickerTrigger as Trigger,
|
||||
ColorPickerContent as Content,
|
||||
ColorPickerArea as Area,
|
||||
ColorPickerHueSlider as HueSlider,
|
||||
ColorPickerAlphaSlider as AlphaSlider,
|
||||
ColorPickerSwatch as Swatch,
|
||||
ColorPickerEyeDropper as EyeDropper,
|
||||
ColorPickerInput as Input,
|
||||
//
|
||||
useColorPickerStore as useColorPicker,
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { GithubLogoIcon, StarIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { CountUp } from "../animation/count-up";
|
||||
|
||||
export function GithubStarsButton() {
|
||||
const { data: starCount } = useQuery(orpc.statistics.github.getStarCount.queryOptions());
|
||||
|
||||
const ariaLabel =
|
||||
starCount != null
|
||||
? t`Star us on GitHub, currently ${starCount.toLocaleString()} stars (opens in new tab)`
|
||||
: t`Star us on GitHub (opens in new tab)`;
|
||||
|
||||
return (
|
||||
<Button asChild variant="outline">
|
||||
<a
|
||||
href="https://github.com/AmruthPillai/Reactive-Resume"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<GithubLogoIcon aria-hidden="true" />
|
||||
{starCount != null ? (
|
||||
<CountUp to={starCount} duration={0.5} separator="," className="font-bold" aria-hidden="true" />
|
||||
) : null}
|
||||
<StarIcon aria-hidden="true" />
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { ProhibitIcon } from "@phosphor-icons/react";
|
||||
import Fuse from "fuse.js";
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { type CellComponentProps, Grid } from "react-window";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { type IconName, icons } from "@/schema/icons";
|
||||
import { cn } from "@/utils/style";
|
||||
import { Input } from "../ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
|
||||
const columnCount = 8;
|
||||
const columnWidth = 36;
|
||||
const rowHeight = 36;
|
||||
|
||||
type IconSearchInputProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function _IconSearchInput(props: IconSearchInputProps) {
|
||||
return (
|
||||
<Input
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
inputMode="search"
|
||||
value={props.value}
|
||||
aria-label={t`Search for an icon`}
|
||||
placeholder={t`Search for an icon`}
|
||||
onChange={(e) => props.onChange(e.currentTarget.value)}
|
||||
className={cn("rounded-none border-0 focus-visible:ring-0", props.className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const IconSearchInput = memo(_IconSearchInput);
|
||||
|
||||
IconSearchInput.displayName = "IconSearchInput";
|
||||
|
||||
type IconCellComponentProps = CellComponentProps & {
|
||||
icons: IconName[];
|
||||
onChange: (icon: IconName) => void;
|
||||
};
|
||||
|
||||
function IconCellComponent({ columnIndex, rowIndex, style, icons, onChange }: IconCellComponentProps) {
|
||||
const index = rowIndex * columnCount + columnIndex;
|
||||
const icon = icons[index];
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={icon}
|
||||
style={style}
|
||||
tabIndex={-1}
|
||||
onClick={() => onChange(icon)}
|
||||
className="flex size-full items-center justify-center hover:bg-accent"
|
||||
>
|
||||
{icon ? <i className={cn("ph text-base", `ph-${icon}`)} /> : <ProhibitIcon />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function useIconSearch() {
|
||||
const fuse = useMemo(() => new Fuse(icons, { threshold: 0.35 }), []);
|
||||
|
||||
const search = useCallback(
|
||||
(query: string): IconName[] => {
|
||||
if (!query.trim()) return Array.from(icons);
|
||||
return fuse.search(query).map((result) => result.item);
|
||||
},
|
||||
[fuse],
|
||||
);
|
||||
|
||||
return search;
|
||||
}
|
||||
|
||||
type IconPickerProps = Omit<React.ComponentProps<typeof Button>, "value" | "onChange"> & {
|
||||
value: string;
|
||||
onChange: (icon: string) => void;
|
||||
popoverProps?: React.ComponentProps<typeof Popover>;
|
||||
};
|
||||
|
||||
export function IconPicker({ value, onChange, popoverProps, ...props }: IconPickerProps) {
|
||||
const searchIcons = useIconSearch();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const searchedIcons = useMemo(() => searchIcons(search), [search, searchIcons]);
|
||||
const rowCount = useMemo(() => Math.ceil(searchedIcons.length / columnCount), [searchedIcons]);
|
||||
|
||||
return (
|
||||
<Popover {...popoverProps}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="icon" variant="outline" {...props}>
|
||||
<i className={cn("ph size-4 text-base", `ph-${value}`)} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="start" className="h-[326px] w-[290px] gap-0 p-0">
|
||||
<IconSearchInput value={search} onChange={setSearch} />
|
||||
|
||||
<div className="size-[290px]">
|
||||
<Grid
|
||||
key={search}
|
||||
rowCount={rowCount}
|
||||
rowHeight={rowHeight}
|
||||
columnCount={columnCount}
|
||||
columnWidth={columnWidth}
|
||||
cellComponent={IconCellComponent}
|
||||
cellProps={{ icons: searchedIcons, onChange }}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
.tiptap_content {
|
||||
--list-margin: 0.25rem;
|
||||
--table-border-color: var(--page-text-color);
|
||||
|
||||
:where(p) {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
:where(p):first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
:where(p):last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:where(ul, ol) {
|
||||
margin-top: var(--list-margin);
|
||||
margin-bottom: var(--list-margin);
|
||||
list-style-position: inside;
|
||||
}
|
||||
|
||||
:where(ul) {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
:where(ol) {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
:where(li) {
|
||||
margin-top: var(--list-margin);
|
||||
margin-bottom: var(--list-margin);
|
||||
}
|
||||
|
||||
:where(li p) {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
:where(li ul li, li ol li) {
|
||||
margin: var(--list-margin) 1rem;
|
||||
}
|
||||
|
||||
:where(mark) {
|
||||
color: var(--page-background-color);
|
||||
background-color: var(--page-primary-color);
|
||||
}
|
||||
|
||||
:where(table) {
|
||||
width: 100%;
|
||||
|
||||
border-top: 1px solid var(--table-border-color);
|
||||
border-left: 1px solid var(--table-border-color);
|
||||
}
|
||||
|
||||
:where(th) {
|
||||
font-weight: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
:where(th, td) {
|
||||
padding: 0.5rem;
|
||||
border-right: 1px solid var(--table-border-color);
|
||||
border-bottom: 1px solid var(--table-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.editor_content {
|
||||
font-family: var(--page-body-font-family);
|
||||
font-size: calc(var(--page-body-font-size) * 1pt);
|
||||
font-weight: var(--page-body-font-weight);
|
||||
line-height: var(--page-body-line-height);
|
||||
color: var(--page-text-color);
|
||||
|
||||
[class*="dark"] & {
|
||||
--table-border-color: var(--color-foreground);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--page-heading-font-family);
|
||||
font-weight: var(--page-heading-font-weight);
|
||||
line-height: var(--page-heading-line-height);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: calc(var(--page-heading-font-size) * 1.5pt);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: calc(var(--page-heading-font-size) * 1.25pt);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: calc(var(--page-heading-font-size) * 1.125pt);
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: calc(var(--page-heading-font-size) * 1pt);
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: calc(var(--page-heading-font-size) * 0.875pt);
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: calc(var(--page-heading-font-size) * 0.75pt);
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: var(--page-body-font-weight-bold);
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: var(--page-body-font-weight-bold);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.15rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
CodeBlockIcon,
|
||||
CodeSimpleIcon,
|
||||
ColumnsPlusLeftIcon,
|
||||
ColumnsPlusRightIcon,
|
||||
HighlighterCircleIcon,
|
||||
KeyReturnIcon,
|
||||
LinkBreakIcon,
|
||||
LinkIcon,
|
||||
ListBulletsIcon,
|
||||
ListNumbersIcon,
|
||||
MinusIcon,
|
||||
ParagraphIcon,
|
||||
PlusIcon,
|
||||
RowsPlusBottomIcon,
|
||||
RowsPlusTopIcon,
|
||||
TableIcon,
|
||||
TextAlignCenterIcon,
|
||||
TextAlignJustifyIcon,
|
||||
TextAlignLeftIcon,
|
||||
TextAlignRightIcon,
|
||||
TextBolderIcon,
|
||||
TextHFiveIcon,
|
||||
TextHFourIcon,
|
||||
TextHOneIcon,
|
||||
TextHSixIcon,
|
||||
TextHThreeIcon,
|
||||
TextHTwoIcon,
|
||||
TextIndentIcon,
|
||||
TextItalicIcon,
|
||||
TextOutdentIcon,
|
||||
TextStrikethroughIcon,
|
||||
TextUnderlineIcon,
|
||||
TrashSimpleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import Highlight from "@tiptap/extension-highlight";
|
||||
import { TableKit } from "@tiptap/extension-table";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
import {
|
||||
type Editor,
|
||||
EditorContent,
|
||||
EditorContext,
|
||||
type UseEditorOptions,
|
||||
useEditor,
|
||||
useEditorState,
|
||||
} from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { match } from "ts-pattern";
|
||||
import z from "zod";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/animate-ui/components/radix/dropdown-menu";
|
||||
import { usePrompt } from "@/hooks/use-prompt";
|
||||
import { isRTL } from "@/utils/locale";
|
||||
import { sanitizeHtml } from "@/utils/sanitize";
|
||||
import { cn } from "@/utils/style";
|
||||
import { Toggle } from "../ui/toggle";
|
||||
import styles from "./rich-input.module.css";
|
||||
|
||||
const extensions = [
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
levels: [1, 2, 3, 4, 5, 6],
|
||||
},
|
||||
codeBlock: {
|
||||
enableTabIndentation: true,
|
||||
},
|
||||
link: {
|
||||
openOnClick: false,
|
||||
enableClickSelection: true,
|
||||
defaultProtocol: "https",
|
||||
protocols: ["http", "https"],
|
||||
},
|
||||
}),
|
||||
Highlight.configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md px-0.5 py-px",
|
||||
},
|
||||
}),
|
||||
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
||||
TableKit.configure(),
|
||||
];
|
||||
|
||||
type Props = UseEditorOptions & {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
editorClassName?: string;
|
||||
};
|
||||
|
||||
export function RichInput({ value, onChange, style, className, editorClassName, ...options }: Props) {
|
||||
const { i18n } = useLingui();
|
||||
const textDirection = isRTL(i18n.locale) ? "rtl" : undefined;
|
||||
|
||||
const editor = useEditor({
|
||||
...options,
|
||||
extensions,
|
||||
textDirection,
|
||||
content: value,
|
||||
immediatelyRender: false,
|
||||
shouldRerenderOnTransaction: false,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
spellcheck: "false",
|
||||
"data-editor": "true",
|
||||
class: cn(
|
||||
"group/editor",
|
||||
"max-h-[400px] min-h-[100px] overflow-y-auto p-3 pb-0",
|
||||
"rounded-md rounded-t-none border outline-none focus-visible:border-ring",
|
||||
"[td:has(.selectedCell)]:bg-primary",
|
||||
styles.tiptap_content,
|
||||
styles.editor_content,
|
||||
editorClassName,
|
||||
),
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
onChange(editor.getHTML());
|
||||
},
|
||||
});
|
||||
|
||||
const providerValue = useMemo(() => ({ editor }), [editor]);
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<EditorContext value={providerValue}>
|
||||
<div className={cn("rounded-md", className)} style={style}>
|
||||
<EditorToolbar editor={editor} />
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</EditorContext>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorToolbar({ editor }: { editor: Editor }) {
|
||||
const prompt = usePrompt();
|
||||
|
||||
const state = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => {
|
||||
return {
|
||||
// Bold
|
||||
isBold: ctx.editor.isActive("bold") ?? false,
|
||||
canBold: ctx.editor.can().chain().toggleBold().run() ?? false,
|
||||
toggleBold: () => ctx.editor.chain().focus().toggleBold().run(),
|
||||
|
||||
// Italic
|
||||
isItalic: ctx.editor.isActive("italic") ?? false,
|
||||
canItalic: ctx.editor.can().chain().toggleItalic().run() ?? false,
|
||||
toggleItalic: () => ctx.editor.chain().focus().toggleItalic().run(),
|
||||
|
||||
// Underline
|
||||
isUnderline: ctx.editor.isActive("underline") ?? false,
|
||||
canUnderline: ctx.editor.can().chain().toggleUnderline().run() ?? false,
|
||||
toggleUnderline: () => ctx.editor.chain().focus().toggleUnderline().run(),
|
||||
|
||||
// Strike
|
||||
isStrike: ctx.editor.isActive("strike") ?? false,
|
||||
canStrike: ctx.editor.can().chain().toggleStrike().run() ?? false,
|
||||
toggleStrike: () => ctx.editor.chain().focus().toggleStrike().run(),
|
||||
|
||||
// Highlight
|
||||
isHighlight: ctx.editor.isActive("highlight") ?? false,
|
||||
canHighlight: ctx.editor.can().chain().toggleHighlight().run() ?? false,
|
||||
toggleHighlight: () => ctx.editor.chain().focus().toggleHighlight().run(),
|
||||
|
||||
// Heading 1
|
||||
isHeading1: ctx.editor.isActive("heading", { level: 1 }) ?? false,
|
||||
canHeading1: ctx.editor.can().chain().toggleHeading({ level: 1 }).run() ?? false,
|
||||
toggleHeading1: () => ctx.editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
||||
|
||||
// Heading 2
|
||||
isHeading2: ctx.editor.isActive("heading", { level: 2 }) ?? false,
|
||||
canHeading2: ctx.editor.can().chain().toggleHeading({ level: 2 }).run() ?? false,
|
||||
toggleHeading2: () => ctx.editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
||||
|
||||
// Heading 3
|
||||
isHeading3: ctx.editor.isActive("heading", { level: 3 }) ?? false,
|
||||
canHeading3: ctx.editor.can().chain().toggleHeading({ level: 3 }).run() ?? false,
|
||||
toggleHeading3: () => ctx.editor.chain().focus().toggleHeading({ level: 3 }).run(),
|
||||
|
||||
// Heading 4
|
||||
isHeading4: ctx.editor.isActive("heading", { level: 4 }) ?? false,
|
||||
canHeading4: ctx.editor.can().chain().toggleHeading({ level: 4 }).run() ?? false,
|
||||
toggleHeading4: () => ctx.editor.chain().focus().toggleHeading({ level: 4 }).run(),
|
||||
|
||||
// Heading 5
|
||||
isHeading5: ctx.editor.isActive("heading", { level: 5 }) ?? false,
|
||||
canHeading5: ctx.editor.can().chain().toggleHeading({ level: 5 }).run() ?? false,
|
||||
toggleHeading5: () => ctx.editor.chain().focus().toggleHeading({ level: 5 }).run(),
|
||||
|
||||
// Heading 6
|
||||
isHeading6: ctx.editor.isActive("heading", { level: 6 }) ?? false,
|
||||
canHeading6: ctx.editor.can().chain().toggleHeading({ level: 6 }).run() ?? false,
|
||||
toggleHeading6: () => ctx.editor.chain().focus().toggleHeading({ level: 6 }).run(),
|
||||
|
||||
// Paragraph
|
||||
isParagraph: ctx.editor.isActive("paragraph") ?? false,
|
||||
canParagraph: ctx.editor.can().chain().setParagraph().run() ?? false,
|
||||
setParagraph: () => ctx.editor.chain().focus().setParagraph().run(),
|
||||
|
||||
// Left Align
|
||||
isLeftAlign: ctx.editor.isActive({ textAlign: "left" }) ?? false,
|
||||
canLeftAlign: ctx.editor.can().chain().toggleTextAlign("left").run() ?? false,
|
||||
toggleLeftAlign: () => ctx.editor.chain().focus().toggleTextAlign("left").run(),
|
||||
|
||||
// Center Align
|
||||
isCenterAlign: ctx.editor.isActive({ textAlign: "center" }) ?? false,
|
||||
canCenterAlign: ctx.editor.can().chain().toggleTextAlign("center").run() ?? false,
|
||||
toggleCenterAlign: () => ctx.editor.chain().focus().toggleTextAlign("center").run(),
|
||||
|
||||
// Right Align
|
||||
isRightAlign: ctx.editor.isActive({ textAlign: "right" }) ?? false,
|
||||
canRightAlign: ctx.editor.can().chain().toggleTextAlign("right").run() ?? false,
|
||||
toggleRightAlign: () => ctx.editor.chain().focus().toggleTextAlign("right").run(),
|
||||
|
||||
// Justify Align
|
||||
isJustifyAlign: ctx.editor.isActive({ textAlign: "justify" }) ?? false,
|
||||
canJustifyAlign: ctx.editor.can().chain().toggleTextAlign("justify").run() ?? false,
|
||||
toggleJustifyAlign: () => ctx.editor.chain().focus().toggleTextAlign("justify").run(),
|
||||
|
||||
// Bullet List
|
||||
isBulletList: ctx.editor.isActive("bulletList") ?? false,
|
||||
canBulletList: ctx.editor.can().chain().toggleBulletList().run() ?? false,
|
||||
toggleBulletList: () => ctx.editor.chain().focus().toggleBulletList().run(),
|
||||
|
||||
// Ordered List
|
||||
isOrderedList: ctx.editor.isActive("orderedList") ?? false,
|
||||
canOrderedList: ctx.editor.can().chain().toggleOrderedList().run() ?? false,
|
||||
toggleOrderedList: () => ctx.editor.chain().focus().toggleOrderedList().run(),
|
||||
|
||||
// Outdent List Item
|
||||
canLiftListItem: ctx.editor.can().chain().liftListItem("listItem").run() ?? false,
|
||||
liftListItem: () => ctx.editor.chain().focus().liftListItem("listItem").run(),
|
||||
|
||||
// Indent List Item
|
||||
canSinkListItem: ctx.editor.can().chain().sinkListItem("listItem").run() ?? false,
|
||||
sinkListItem: () => ctx.editor.chain().focus().sinkListItem("listItem").run(),
|
||||
|
||||
// Link
|
||||
isLink: ctx.editor.isActive("link") ?? false,
|
||||
setLink: async () => {
|
||||
const url = await prompt(t`Please enter the URL you want to link to:`, {
|
||||
defaultValue: "https://",
|
||||
});
|
||||
|
||||
if (!url || url.trim() === "") {
|
||||
ctx.editor.chain().focus().unsetLink().run();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!z.url({ protocol: /^https?$/ }).safeParse(url).success) {
|
||||
toast.error(t`The URL you entered is not valid.`, {
|
||||
description: t`Valid URLs must start with http:// or https://.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setLink({ href: url, target: "_blank", rel: "noopener noreferrer nofollow" })
|
||||
.run();
|
||||
},
|
||||
unsetLink: () => ctx.editor.chain().focus().unsetLink().run(),
|
||||
|
||||
// Inline Code
|
||||
isInlineCode: ctx.editor.isActive("code") ?? false,
|
||||
canInlineCode: ctx.editor.can().chain().toggleCode().run() ?? false,
|
||||
toggleInlineCode: () => ctx.editor.chain().focus().toggleCode().run(),
|
||||
|
||||
// Code Block
|
||||
isCodeBlock: ctx.editor.isActive("codeBlock") ?? false,
|
||||
canCodeBlock: ctx.editor.can().chain().toggleCodeBlock().run() ?? false,
|
||||
toggleCodeBlock: () => ctx.editor.chain().focus().toggleCodeBlock().run(),
|
||||
|
||||
// Table
|
||||
insertTable: () => ctx.editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
|
||||
canInsertTable: ctx.editor.can().chain().insertTable().run() ?? false,
|
||||
addColumnBefore: () => ctx.editor.chain().focus().addColumnBefore().run(),
|
||||
canAddColumnBefore: ctx.editor.can().chain().addColumnBefore().run() ?? false,
|
||||
addColumnAfter: () => ctx.editor.chain().focus().addColumnAfter().run(),
|
||||
canAddColumnAfter: ctx.editor.can().chain().addColumnAfter().run() ?? false,
|
||||
addRowBefore: () => ctx.editor.chain().focus().addRowBefore().run(),
|
||||
canAddRowBefore: ctx.editor.can().chain().addRowBefore().run() ?? false,
|
||||
addRowAfter: () => ctx.editor.chain().focus().addRowAfter().run(),
|
||||
canAddRowAfter: ctx.editor.can().chain().addRowAfter().run() ?? false,
|
||||
deleteColumn: () => ctx.editor.chain().focus().deleteColumn().run(),
|
||||
canDeleteColumn: ctx.editor.can().chain().deleteColumn().run() ?? false,
|
||||
deleteRow: () => ctx.editor.chain().focus().deleteRow().run(),
|
||||
canDeleteRow: ctx.editor.can().chain().deleteRow().run() ?? false,
|
||||
deleteTable: () => ctx.editor.chain().focus().deleteTable().run(),
|
||||
canDeleteTable: ctx.editor.can().chain().deleteTable().run() ?? false,
|
||||
|
||||
// Hard Break
|
||||
setHardBreak: () => ctx.editor.chain().focus().setHardBreak().run(),
|
||||
|
||||
// Horizontal Rule
|
||||
setHorizontalRule: () => ctx.editor.chain().focus().setHorizontalRule().run(),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-y-0.5 rounded-md rounded-b-none border border-b-0">
|
||||
<Toggle
|
||||
size="sm"
|
||||
tabIndex={-1}
|
||||
className="rounded-none"
|
||||
title={t`Bold`}
|
||||
pressed={state.isBold}
|
||||
disabled={!state.canBold}
|
||||
onPressedChange={state.toggleBold}
|
||||
>
|
||||
<TextBolderIcon className="size-3.5" />
|
||||
</Toggle>
|
||||
|
||||
<Toggle
|
||||
size="sm"
|
||||
tabIndex={-1}
|
||||
className="rounded-none"
|
||||
title={t`Italic`}
|
||||
pressed={state.isItalic}
|
||||
disabled={!state.canItalic}
|
||||
onPressedChange={state.toggleItalic}
|
||||
>
|
||||
<TextItalicIcon className="size-3.5" />
|
||||
</Toggle>
|
||||
|
||||
<Toggle
|
||||
size="sm"
|
||||
tabIndex={-1}
|
||||
className="rounded-none"
|
||||
title={t`Underline`}
|
||||
pressed={state.isUnderline}
|
||||
disabled={!state.canUnderline}
|
||||
onPressedChange={state.toggleUnderline}
|
||||
>
|
||||
<TextUnderlineIcon className="size-3.5" />
|
||||
</Toggle>
|
||||
|
||||
<Toggle
|
||||
size="sm"
|
||||
tabIndex={-1}
|
||||
className="rounded-none"
|
||||
title={t`Strike`}
|
||||
pressed={state.isStrike}
|
||||
disabled={!state.canStrike}
|
||||
onPressedChange={state.toggleStrike}
|
||||
>
|
||||
<TextStrikethroughIcon className="size-3.5" />
|
||||
</Toggle>
|
||||
|
||||
<Toggle
|
||||
size="sm"
|
||||
tabIndex={-1}
|
||||
className="rounded-none"
|
||||
title={t`Highlight`}
|
||||
pressed={state.isHighlight}
|
||||
disabled={!state.canHighlight}
|
||||
onPressedChange={state.toggleHighlight}
|
||||
>
|
||||
<HighlighterCircleIcon className="size-3.5" />
|
||||
</Toggle>
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-border" />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" tabIndex={-1} variant="ghost" className="rounded-none">
|
||||
{match(state)
|
||||
.with({ isParagraph: true }, () => <ParagraphIcon className="size-3.5" />)
|
||||
.with({ isHeading1: true }, () => <TextHOneIcon className="size-3.5" />)
|
||||
.with({ isHeading2: true }, () => <TextHTwoIcon className="size-3.5" />)
|
||||
.with({ isHeading3: true }, () => <TextHThreeIcon className="size-3.5" />)
|
||||
.with({ isHeading4: true }, () => <TextHFourIcon className="size-3.5" />)
|
||||
.with({ isHeading5: true }, () => <TextHFiveIcon className="size-3.5" />)
|
||||
.with({ isHeading6: true }, () => <TextHSixIcon className="size-3.5" />)
|
||||
.otherwise(() => (
|
||||
<ParagraphIcon className="size-3.5" />
|
||||
))}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuCheckboxItem
|
||||
disabled={!state.canParagraph}
|
||||
checked={state.isParagraph}
|
||||
onCheckedChange={state.setParagraph}
|
||||
>
|
||||
<Trans>Paragraph</Trans>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
disabled={!state.canHeading1}
|
||||
checked={state.isHeading1}
|
||||
onCheckedChange={state.toggleHeading1}
|
||||
>
|
||||
<Trans>Heading 1</Trans>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
disabled={!state.canHeading2}
|
||||
checked={state.isHeading2}
|
||||
onCheckedChange={state.toggleHeading2}
|
||||
>
|
||||
<Trans>Heading 2</Trans>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
disabled={!state.canHeading3}
|
||||
checked={state.isHeading3}
|
||||
onCheckedChange={state.toggleHeading3}
|
||||
>
|
||||
<Trans>Heading 3</Trans>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
disabled={!state.canHeading4}
|
||||
checked={state.isHeading4}
|
||||
onCheckedChange={state.toggleHeading4}
|
||||
>
|
||||
<Trans>Heading 4</Trans>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
disabled={!state.canHeading5}
|
||||
checked={state.isHeading5}
|
||||
onCheckedChange={state.toggleHeading5}
|
||||
>
|
||||
<Trans>Heading 5</Trans>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
disabled={!state.canHeading6}
|
||||
checked={state.isHeading6}
|
||||
onCheckedChange={state.toggleHeading6}
|
||||
>
|
||||
<Trans>Heading 6</Trans>
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" tabIndex={-1} variant="ghost" className="rounded-none">
|
||||
{match(state)
|
||||
.with({ isLeftAlign: true }, () => <TextAlignLeftIcon className="size-3.5" />)
|
||||
.with({ isCenterAlign: true }, () => <TextAlignCenterIcon className="size-3.5" />)
|
||||
.with({ isRightAlign: true }, () => <TextAlignRightIcon className="size-3.5" />)
|
||||
.with({ isJustifyAlign: true }, () => <TextAlignJustifyIcon className="size-3.5" />)
|
||||
.otherwise(() => (
|
||||
<TextAlignLeftIcon className="size-3.5" />
|
||||
))}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuCheckboxItem
|
||||
disabled={!state.canLeftAlign}
|
||||
checked={state.isLeftAlign}
|
||||
onCheckedChange={state.toggleLeftAlign}
|
||||
>
|
||||
<Trans>Left Align</Trans>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
disabled={!state.canCenterAlign}
|
||||
checked={state.isCenterAlign}
|
||||
onCheckedChange={state.toggleCenterAlign}
|
||||
>
|
||||
<Trans>Center Align</Trans>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
disabled={!state.canRightAlign}
|
||||
checked={state.isRightAlign}
|
||||
onCheckedChange={state.toggleRightAlign}
|
||||
>
|
||||
<Trans>Right Align</Trans>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
disabled={!state.canJustifyAlign}
|
||||
checked={state.isJustifyAlign}
|
||||
onCheckedChange={state.toggleJustifyAlign}
|
||||
>
|
||||
<Trans>Justify Align</Trans>
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-border" />
|
||||
|
||||
<Toggle
|
||||
size="sm"
|
||||
tabIndex={-1}
|
||||
className="rounded-none"
|
||||
title={t`Bullet List`}
|
||||
pressed={state.isBulletList}
|
||||
disabled={!state.canBulletList}
|
||||
onPressedChange={state.toggleBulletList}
|
||||
>
|
||||
<ListBulletsIcon className="size-3.5" />
|
||||
</Toggle>
|
||||
|
||||
<Toggle
|
||||
size="sm"
|
||||
tabIndex={-1}
|
||||
className="rounded-none"
|
||||
title={t`Ordered List`}
|
||||
pressed={state.isOrderedList}
|
||||
disabled={!state.canOrderedList}
|
||||
onPressedChange={state.toggleOrderedList}
|
||||
>
|
||||
<ListNumbersIcon className="size-3.5" />
|
||||
</Toggle>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
disabled={!state.canLiftListItem}
|
||||
onClick={state.liftListItem}
|
||||
>
|
||||
<TextOutdentIcon className="size-3.5" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
disabled={!state.canSinkListItem}
|
||||
onClick={state.sinkListItem}
|
||||
>
|
||||
<TextIndentIcon className="size-3.5" />
|
||||
</Button>
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-border" />
|
||||
|
||||
{state.isLink ? (
|
||||
<Button size="sm" tabIndex={-1} variant="ghost" className="rounded-none" onClick={state.unsetLink}>
|
||||
<LinkBreakIcon className="size-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" tabIndex={-1} variant="ghost" className="rounded-none" onClick={state.setLink}>
|
||||
<LinkIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Toggle
|
||||
size="sm"
|
||||
tabIndex={-1}
|
||||
className="rounded-none"
|
||||
title={t`Inline Code`}
|
||||
pressed={state.isInlineCode}
|
||||
disabled={!state.canInlineCode}
|
||||
onPressedChange={state.toggleInlineCode}
|
||||
>
|
||||
<CodeSimpleIcon className="size-3.5" />
|
||||
</Toggle>
|
||||
|
||||
<Toggle
|
||||
size="sm"
|
||||
tabIndex={-1}
|
||||
className="rounded-none"
|
||||
title={t`Code Block`}
|
||||
pressed={state.isCodeBlock}
|
||||
disabled={!state.canCodeBlock}
|
||||
onPressedChange={state.toggleCodeBlock}
|
||||
>
|
||||
<CodeBlockIcon className="size-3.5" />
|
||||
</Toggle>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" tabIndex={-1} variant="ghost" className="rounded-none" title={t`Table`}>
|
||||
<TableIcon className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem disabled={!state.canInsertTable} onSelect={state.insertTable}>
|
||||
<PlusIcon />
|
||||
<Trans>Insert Table</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={!state.canAddColumnBefore} onSelect={state.addColumnBefore}>
|
||||
<ColumnsPlusLeftIcon />
|
||||
<Trans>Add Column Before</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!state.canAddColumnAfter} onSelect={state.addColumnAfter}>
|
||||
<ColumnsPlusRightIcon />
|
||||
<Trans>Add Column After</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={!state.canAddRowBefore} onSelect={state.addRowBefore}>
|
||||
<RowsPlusTopIcon />
|
||||
<Trans>Add Row Before</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!state.canAddRowAfter} onSelect={state.addRowAfter}>
|
||||
<RowsPlusBottomIcon />
|
||||
<Trans>Add Row After</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={!state.canDeleteColumn} onSelect={state.deleteColumn}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete Column</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!state.canDeleteRow} onSelect={state.deleteRow}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete Row</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" disabled={!state.canDeleteTable} onSelect={state.deleteTable}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete Table</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
title={t`New Line`}
|
||||
onClick={state.setHardBreak}
|
||||
>
|
||||
<KeyReturnIcon className="size-3.5" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
className="rounded-none"
|
||||
title={t`Separator`}
|
||||
onClick={state.setHorizontalRule}
|
||||
>
|
||||
<MinusIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TiptapContentProps = React.ComponentProps<"div"> & {
|
||||
content: string;
|
||||
};
|
||||
|
||||
export function TiptapContent({ content, className, ...props }: TiptapContentProps) {
|
||||
const sanitizedContent = useMemo(() => sanitizeHtml(content), [content]);
|
||||
|
||||
return (
|
||||
<div
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: Content is sanitized with DOMPurify
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
|
||||
className={cn(styles.tiptap_content, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { TagIcon } from "@phosphor-icons/react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type z from "zod";
|
||||
import type { urlSchema } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { Input } from "../ui/input";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText } from "../ui/input-group";
|
||||
import { Label } from "../ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
|
||||
const PREFIX = "https://";
|
||||
|
||||
function stripPrefix(url: string) {
|
||||
return url.startsWith(PREFIX) ? url.slice(PREFIX.length) : url;
|
||||
}
|
||||
|
||||
function ensurePrefix(url: string) {
|
||||
if (url === "") return "";
|
||||
return url.startsWith(PREFIX) ? url : PREFIX + url;
|
||||
}
|
||||
|
||||
type Props = Omit<React.ComponentProps<"input">, "value" | "onChange"> & {
|
||||
value: z.infer<typeof urlSchema>;
|
||||
onChange: (value: z.infer<typeof urlSchema>) => void;
|
||||
};
|
||||
|
||||
export function URLInput({ value, onChange, ...props }: Props) {
|
||||
const handleUrlChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange({
|
||||
url: ensurePrefix(e.target.value),
|
||||
label: value.label,
|
||||
});
|
||||
},
|
||||
[onChange, value.label],
|
||||
);
|
||||
|
||||
const handleLabelChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange({ url: value.url, label: e.target.value });
|
||||
},
|
||||
[onChange, value.url],
|
||||
);
|
||||
|
||||
const urlValue = useMemo(() => stripPrefix(value.url), [value.url]);
|
||||
|
||||
return (
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<InputGroupText>{PREFIX}</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
|
||||
<InputGroupInput
|
||||
value={urlValue}
|
||||
className={cn(props.className, "pl-0!")}
|
||||
onChange={handleUrlChange}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
<InputGroupAddon align="inline-end">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<InputGroupButton size="icon-sm" title={t`Add a label to the URL`}>
|
||||
<TagIcon />
|
||||
</InputGroupButton>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="pt-3">
|
||||
<div className="grid gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Label htmlFor="url-label">
|
||||
<Trans>Label</Trans>
|
||||
</Label>
|
||||
<Input id="url-label" name="url-label" value={value.label} onChange={handleLabelChange} />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import * as React from "react";
|
||||
|
||||
type InputValue = string[] | string;
|
||||
|
||||
interface VisuallyHiddenInputProps<T = InputValue>
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "checked" | "onReset"> {
|
||||
value?: T;
|
||||
checked?: boolean;
|
||||
control: HTMLElement | null;
|
||||
bubbles?: boolean;
|
||||
}
|
||||
|
||||
export function VisuallyHiddenInput<T = InputValue>(props: VisuallyHiddenInputProps<T>) {
|
||||
const { control, value, checked, bubbles = true, type = "hidden", style, ...inputProps } = props;
|
||||
|
||||
const isCheckInput = React.useMemo(() => type === "checkbox" || type === "radio" || type === "switch", [type]);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const prevValueRef = React.useRef<{
|
||||
value: T | boolean | undefined;
|
||||
previous: T | boolean | undefined;
|
||||
}>({
|
||||
value: isCheckInput ? checked : value,
|
||||
previous: isCheckInput ? checked : value,
|
||||
});
|
||||
|
||||
const prevValue = React.useMemo(() => {
|
||||
const currentValue = isCheckInput ? checked : value;
|
||||
if (prevValueRef.current.value !== currentValue) {
|
||||
prevValueRef.current.previous = prevValueRef.current.value;
|
||||
prevValueRef.current.value = currentValue;
|
||||
}
|
||||
return prevValueRef.current.previous;
|
||||
}, [isCheckInput, value, checked]);
|
||||
|
||||
const [controlSize, setControlSize] = React.useState<{
|
||||
width?: number;
|
||||
height?: number;
|
||||
}>({});
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!control) {
|
||||
setControlSize({});
|
||||
return;
|
||||
}
|
||||
|
||||
setControlSize({
|
||||
width: control.offsetWidth,
|
||||
height: control.offsetHeight,
|
||||
});
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
if (!Array.isArray(entries) || !entries.length) return;
|
||||
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
|
||||
let width: number;
|
||||
let height: number;
|
||||
|
||||
if ("borderBoxSize" in entry) {
|
||||
const borderSizeEntry = entry.borderBoxSize;
|
||||
const borderSize = Array.isArray(borderSizeEntry) ? borderSizeEntry[0] : borderSizeEntry;
|
||||
width = borderSize.inlineSize;
|
||||
height = borderSize.blockSize;
|
||||
} else {
|
||||
width = control.offsetWidth;
|
||||
height = control.offsetHeight;
|
||||
}
|
||||
|
||||
setControlSize({ width, height });
|
||||
});
|
||||
|
||||
resizeObserver.observe(control, { box: "border-box" });
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [control]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
|
||||
const inputProto = window.HTMLInputElement.prototype;
|
||||
const propertyKey = isCheckInput ? "checked" : "value";
|
||||
const eventType = isCheckInput ? "click" : "input";
|
||||
const currentValue = isCheckInput ? checked : value;
|
||||
|
||||
const serializedCurrentValue = isCheckInput
|
||||
? checked
|
||||
: typeof value === "object" && value !== null
|
||||
? JSON.stringify(value)
|
||||
: value;
|
||||
|
||||
const descriptor = Object.getOwnPropertyDescriptor(inputProto, propertyKey);
|
||||
|
||||
const setter = descriptor?.set;
|
||||
|
||||
if (prevValue !== currentValue && setter) {
|
||||
const event = new Event(eventType, { bubbles });
|
||||
setter.call(input, serializedCurrentValue);
|
||||
input.dispatchEvent(event);
|
||||
}
|
||||
}, [prevValue, value, checked, bubbles, isCheckInput]);
|
||||
|
||||
const composedStyle = React.useMemo<React.CSSProperties>(() => {
|
||||
return {
|
||||
...style,
|
||||
...(controlSize.width !== undefined && controlSize.height !== undefined ? controlSize : {}),
|
||||
border: 0,
|
||||
clip: "rect(0 0 0 0)",
|
||||
clipPath: "inset(50%)",
|
||||
height: "1px",
|
||||
margin: "-1px",
|
||||
overflow: "hidden",
|
||||
padding: 0,
|
||||
position: "absolute",
|
||||
whiteSpace: "nowrap",
|
||||
width: "1px",
|
||||
};
|
||||
}, [style, controlSize]);
|
||||
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
{...inputProps}
|
||||
ref={inputRef}
|
||||
aria-hidden={isCheckInput}
|
||||
tabIndex={-1}
|
||||
defaultChecked={isCheckInput ? checked : undefined}
|
||||
style={composedStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type BreakpointIndicatorProps = {
|
||||
position?: "top-right" | "bottom-right" | "top-left" | "bottom-left";
|
||||
};
|
||||
|
||||
export function BreakpointIndicator({ position = "bottom-right" }: BreakpointIndicatorProps) {
|
||||
const isTop = position.includes("top");
|
||||
const isRight = position.includes("right");
|
||||
const isLeft = position.includes("left");
|
||||
const isBottom = position.includes("bottom");
|
||||
|
||||
const top = isTop ? "top-0" : "bottom-0";
|
||||
const right = isRight ? "right-0" : "left-0";
|
||||
const left = isLeft ? "left-0" : "right-0";
|
||||
const bottom = isBottom ? "bottom-0" : "top-0";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed z-50 flex size-10 items-center justify-center bg-blue-900 p-2 font-bold font-mono text-white text-xs opacity-80 transition-opacity hover:opacity-40 print:hidden",
|
||||
top,
|
||||
right,
|
||||
left,
|
||||
bottom,
|
||||
)}
|
||||
>
|
||||
<span className="inline sm:hidden">XS</span>
|
||||
<span className="hidden sm:inline md:hidden">SM</span>
|
||||
<span className="hidden md:inline lg:hidden">MD</span>
|
||||
<span className="hidden lg:inline xl:hidden">LG</span>
|
||||
<span className="hidden xl:inline 2xl:hidden">XL</span>
|
||||
<span className="3xl:hidden hidden 2xl:inline">2XL</span>
|
||||
<span className="3xl:inline 4xl:hidden hidden">3XL</span>
|
||||
<span className="4xl:inline hidden">4XL</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowClockwiseIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import type { ErrorComponentProps } from "@tanstack/react-router";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
|
||||
import { BrandIcon } from "../ui/brand-icon";
|
||||
|
||||
export function ErrorScreen({ error, reset }: ErrorComponentProps) {
|
||||
return (
|
||||
<div className="mx-auto flex h-svh max-w-md flex-col items-center justify-center gap-y-4">
|
||||
<BrandIcon variant="logo" className="size-12" />
|
||||
|
||||
<Alert>
|
||||
<WarningIcon />
|
||||
<AlertTitle>
|
||||
<Trans>An error occurred while loading the page.</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>{error.message}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button onClick={reset}>
|
||||
<ArrowClockwiseIcon />
|
||||
<Trans>Refresh</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
export function LoadingScreen() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex h-svh w-svw items-center justify-center gap-x-3 bg-background">
|
||||
<Spinner className="size-6" />
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>Loading...</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowLeftIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import { Link, type NotFoundRouteProps } from "@tanstack/react-router";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
|
||||
import { BrandIcon } from "../ui/brand-icon";
|
||||
|
||||
export function NotFoundScreen({ routeId }: NotFoundRouteProps) {
|
||||
return (
|
||||
<div className="mx-auto flex h-svh max-w-md flex-col items-center justify-center gap-y-4">
|
||||
<BrandIcon variant="logo" className="size-12" />
|
||||
|
||||
<Alert>
|
||||
<WarningIcon />
|
||||
<AlertTitle>
|
||||
<Trans>An error occurred while loading the page.</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>{routeId}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button asChild>
|
||||
<Link to="..">
|
||||
<ArrowLeftIcon />
|
||||
<Trans>Go Back</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useMemo } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import type z from "zod";
|
||||
import { levelDesignSchema } from "@/schema/resume/data";
|
||||
import { Combobox, type ComboboxProps } from "../ui/combobox";
|
||||
|
||||
export type LevelType = z.infer<typeof levelDesignSchema>["type"];
|
||||
|
||||
type LevelTypeComboboxProps = Omit<ComboboxProps, "options">;
|
||||
|
||||
export const getLevelTypeName = (type: LevelType) => {
|
||||
return match(type)
|
||||
.with("hidden", () => t`Hidden`)
|
||||
.with("circle", () => t`Circle`)
|
||||
.with("square", () => t`Square`)
|
||||
.with("rectangle", () => t`Rectangle`)
|
||||
.with("rectangle-full", () => t`Rectangle (Full Width)`)
|
||||
.with("progress-bar", () => t`Progress Bar`)
|
||||
.with("icon", () => t`Icon`)
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
export function LevelTypeCombobox({ ...props }: LevelTypeComboboxProps) {
|
||||
const options = useMemo(() => {
|
||||
return levelDesignSchema.shape.type.options.map((option) => ({
|
||||
value: option,
|
||||
label: getLevelTypeName(option),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return <Combobox options={options} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import type z from "zod";
|
||||
import type { levelDesignSchema } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { PageIcon } from "../resume/shared/page-icon";
|
||||
|
||||
type Props = z.infer<typeof levelDesignSchema> & React.ComponentProps<"div"> & { level: number };
|
||||
|
||||
export function LevelDisplay({ icon, type, level, className, ...props }: Props) {
|
||||
if (level === 0) return null;
|
||||
if (type === "hidden") return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="presentation"
|
||||
aria-label={t`Level ${level} of 5`}
|
||||
className={cn(
|
||||
"flex items-center gap-x-1.5",
|
||||
type === "progress-bar" && "gap-x-0",
|
||||
type === "rectangle-full" && "gap-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{Array.from({ length: 5 }).map((_, index) => {
|
||||
const isActive = index < level;
|
||||
|
||||
if (type === "progress-bar") {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"h-2.5 flex-1 border border-(--page-primary-color) border-x-0 first:border-l last:border-r",
|
||||
isActive && "bg-(--page-primary-color)",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "icon") {
|
||||
return (
|
||||
<PageIcon
|
||||
key={index}
|
||||
icon={icon}
|
||||
className={cn("h-3 overflow-visible text-(--page-primary-color)", !isActive && "opacity-40")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"size-2.5 border border-(--page-primary-color)",
|
||||
isActive && "bg-(--page-primary-color)",
|
||||
type === "circle" && "rounded-full",
|
||||
type === "rectangle" && "w-7",
|
||||
type === "rectangle-full" && "w-auto flex-1",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { i18n } from "@lingui/core";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { isLocale, type Locale, loadLocale, localeMap, setLocaleServerFn } from "@/utils/locale";
|
||||
import { Combobox, type ComboboxProps } from "../ui/combobox";
|
||||
|
||||
type Props = Omit<ComboboxProps, "options" | "value" | "onValueChange">;
|
||||
|
||||
export const getLocaleOptions = () => {
|
||||
return Object.entries(localeMap).map(([value, label]) => ({
|
||||
value: value as Locale,
|
||||
label: i18n.t(label),
|
||||
keywords: [i18n.t(label)],
|
||||
}));
|
||||
};
|
||||
|
||||
export function LocaleCombobox(props: Props) {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const onLocaleChange = async (value: string | null) => {
|
||||
if (!value || !isLocale(value)) return;
|
||||
await Promise.all([loadLocale(value), setLocaleServerFn({ data: value })]);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return <Combobox options={getLocaleOptions()} defaultValue={i18n.locale} onValueChange={onLocaleChange} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useMemo } from "react";
|
||||
import type z from "zod";
|
||||
import type { resumeDataSchema } from "@/schema/resume/data";
|
||||
|
||||
const pageDimensions = {
|
||||
a4: {
|
||||
width: "210mm",
|
||||
height: "297mm",
|
||||
},
|
||||
letter: {
|
||||
width: "216mm",
|
||||
height: "279mm",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type UseCssVariablesProps = Pick<z.infer<typeof resumeDataSchema>, "picture" | "metadata">;
|
||||
|
||||
export const useCSSVariables = ({ picture, metadata }: UseCssVariablesProps) => {
|
||||
const fontWeightStyles = useMemo(() => {
|
||||
const lowestBodyFontWeight = Math.min(...metadata.typography.body.fontWeights.map(Number));
|
||||
const lowestHeadingFontWeight = Math.min(...metadata.typography.heading.fontWeights.map(Number));
|
||||
|
||||
const highestBodyFontWeight = Math.max(...metadata.typography.body.fontWeights.map(Number));
|
||||
const highestHeadingFontWeight = Math.max(...metadata.typography.heading.fontWeights.map(Number));
|
||||
|
||||
return { lowestBodyFontWeight, lowestHeadingFontWeight, highestBodyFontWeight, highestHeadingFontWeight };
|
||||
}, [metadata.typography.body.fontWeights, metadata.typography.heading.fontWeights]);
|
||||
|
||||
return {
|
||||
"--picture-border-radius": `${picture.borderRadius}pt`,
|
||||
"--page-width": pageDimensions[metadata.page.format].width,
|
||||
"--page-height": pageDimensions[metadata.page.format].height,
|
||||
"--page-sidebar-width": `${metadata.layout.sidebarWidth}%`,
|
||||
"--page-text-color": metadata.design.colors.text,
|
||||
"--page-primary-color": metadata.design.colors.primary,
|
||||
"--page-background-color": metadata.design.colors.background,
|
||||
"--page-body-font-family": `'${metadata.typography.body.fontFamily}', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`,
|
||||
"--page-body-font-weight": fontWeightStyles.lowestBodyFontWeight,
|
||||
"--page-body-font-weight-bold": fontWeightStyles.highestBodyFontWeight,
|
||||
"--page-body-font-size": metadata.typography.body.fontSize,
|
||||
"--page-body-line-height": metadata.typography.body.lineHeight,
|
||||
"--page-heading-font-family": `'${metadata.typography.heading.fontFamily}', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`,
|
||||
"--page-heading-font-weight": fontWeightStyles.lowestHeadingFontWeight,
|
||||
"--page-heading-font-weight-bold": fontWeightStyles.highestHeadingFontWeight,
|
||||
"--page-heading-font-size": metadata.typography.heading.fontSize,
|
||||
"--page-heading-line-height": metadata.typography.heading.lineHeight,
|
||||
"--page-margin-x": `${metadata.page.marginX}pt`,
|
||||
"--page-margin-y": `${metadata.page.marginY}pt`,
|
||||
"--page-gap-x": `${metadata.page.gapX}pt`,
|
||||
"--page-gap-y": `${metadata.page.gapY}pt`,
|
||||
} as React.CSSProperties;
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect } from "react";
|
||||
import type z from "zod";
|
||||
import webfontlist from "@/components/typography/webfontlist.json";
|
||||
import type { typographySchema } from "@/schema/resume/data";
|
||||
|
||||
export function useWebfonts(typography: z.infer<typeof typographySchema>) {
|
||||
useEffect(() => {
|
||||
async function loadFont(family: string, weights: string[]) {
|
||||
const font = webfontlist.find((font) => font.family === family);
|
||||
if (!font) return;
|
||||
|
||||
type FontUrl = { url: string; weight: string; style: "italic" | "normal" };
|
||||
|
||||
const fontUrls: FontUrl[] = [];
|
||||
|
||||
for (const weight of weights) {
|
||||
for (const [fileWeight, url] of Object.entries(font.files)) {
|
||||
if (weight === fileWeight) {
|
||||
fontUrls.push({ url, weight, style: "normal" });
|
||||
}
|
||||
if (fileWeight === `${weight}italic`) {
|
||||
fontUrls.push({ url, weight, style: "italic" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const { url, weight, style } of fontUrls) {
|
||||
const fontFace = new FontFace(family, `url("${url}")`, { style, weight, display: "swap" });
|
||||
if (!document.fonts.has(fontFace)) document.fonts.add(await fontFace.load());
|
||||
}
|
||||
}
|
||||
|
||||
const bodyTypography = typography.body;
|
||||
const headingTypography = typography.heading;
|
||||
|
||||
Promise.all([
|
||||
loadFont(bodyTypography.fontFamily, bodyTypography.fontWeights),
|
||||
loadFont(headingTypography.fontFamily, headingTypography.fontWeights),
|
||||
]);
|
||||
}, [typography]);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
.page {
|
||||
width: var(--page-width);
|
||||
min-height: var(--page-height);
|
||||
font-family: var(--page-body-font-family);
|
||||
font-size: calc(var(--page-body-font-size) * 1pt);
|
||||
font-weight: var(--page-body-font-weight);
|
||||
line-height: var(--page-body-line-height);
|
||||
color: var(--page-text-color);
|
||||
background-color: var(--page-background-color);
|
||||
|
||||
&:not(:first-child) {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: var(--page-width) var(--page-height);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--page-heading-font-family);
|
||||
font-weight: var(--page-heading-font-weight);
|
||||
line-height: var(--page-heading-line-height);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: calc(var(--page-heading-font-size) * 1.5pt);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: calc(var(--page-heading-font-size) * 1.25pt);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: calc(var(--page-heading-font-size) * 1.125pt);
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: calc(var(--page-heading-font-size) * 1pt);
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: calc(var(--page-heading-font-size) * 0.875pt);
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: calc(var(--page-heading-font-size) * 0.75pt);
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.15rem;
|
||||
}
|
||||
|
||||
@media print {
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: var(--page-body-font-weight-bold);
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: calc(var(--page-body-font-size) * 0.9pt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { IconContext, type IconProps } from "@phosphor-icons/react";
|
||||
import { useMemo } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import type { Template } from "@/schema/templates";
|
||||
import { sanitizeCss } from "@/utils/sanitize";
|
||||
import { cn } from "@/utils/style";
|
||||
import { useCSSVariables } from "./hooks/use-css-variables";
|
||||
import { useWebfonts } from "./hooks/use-webfonts";
|
||||
import styles from "./preview.module.css";
|
||||
import { useResumeStore } from "./store/resume";
|
||||
import { AzurillTemplate } from "./templates/azurill";
|
||||
import { BronzorTemplate } from "./templates/bronzor";
|
||||
import { ChikoritaTemplate } from "./templates/chikorita";
|
||||
import { DitgarTemplate } from "./templates/ditgar";
|
||||
import { DittoTemplate } from "./templates/ditto";
|
||||
import { GengarTemplate } from "./templates/gengar";
|
||||
import { GlalieTemplate } from "./templates/glalie";
|
||||
import { KakunaTemplate } from "./templates/kakuna";
|
||||
import { LaprasTemplate } from "./templates/lapras";
|
||||
import { LeafishTemplate } from "./templates/leafish";
|
||||
import { OnyxTemplate } from "./templates/onyx";
|
||||
import { PikachuTemplate } from "./templates/pikachu";
|
||||
import { RhyhornTemplate } from "./templates/rhyhorn";
|
||||
|
||||
export type ExtendedIconProps = IconProps & {
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
type Props = React.ComponentProps<"div"> & {
|
||||
pageClassName?: string;
|
||||
showPageNumbers?: boolean;
|
||||
};
|
||||
|
||||
const CSS_RULE_SPLIT_PATTERN = /\n(?=\s*[.#a-zA-Z])/;
|
||||
const CSS_SELECTOR_PATTERN = /^([^{]+)(\{)/;
|
||||
|
||||
function getTemplateComponent(template: Template) {
|
||||
return match(template)
|
||||
.with("azurill", () => AzurillTemplate)
|
||||
.with("bronzor", () => BronzorTemplate)
|
||||
.with("chikorita", () => ChikoritaTemplate)
|
||||
.with("ditto", () => DittoTemplate)
|
||||
.with("ditgar", () => DitgarTemplate)
|
||||
.with("gengar", () => GengarTemplate)
|
||||
.with("glalie", () => GlalieTemplate)
|
||||
.with("kakuna", () => KakunaTemplate)
|
||||
.with("lapras", () => LaprasTemplate)
|
||||
.with("leafish", () => LeafishTemplate)
|
||||
.with("onyx", () => OnyxTemplate)
|
||||
.with("pikachu", () => PikachuTemplate)
|
||||
.with("rhyhorn", () => RhyhornTemplate)
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
export const ResumePreview = ({ showPageNumbers, pageClassName, className, ...props }: Props) => {
|
||||
const picture = useResumeStore((state) => state.resume.data.picture);
|
||||
const metadata = useResumeStore((state) => state.resume.data.metadata);
|
||||
|
||||
useWebfonts(metadata.typography);
|
||||
const style = useCSSVariables({ picture, metadata });
|
||||
const totalNumberOfPages = metadata.layout.pages.length;
|
||||
|
||||
const iconProps = useMemo<ExtendedIconProps>(() => {
|
||||
return {
|
||||
weight: "regular",
|
||||
hidden: metadata.page.hideIcons,
|
||||
color: "var(--page-primary-color)",
|
||||
size: metadata.typography.body.fontSize * 1.5,
|
||||
} satisfies ExtendedIconProps;
|
||||
}, [metadata.typography.body.fontSize, metadata.page.hideIcons]);
|
||||
|
||||
const scopedCSS = useMemo(() => {
|
||||
if (!metadata.css.enabled || !metadata.css.value.trim()) return null;
|
||||
|
||||
const sanitizedCss = sanitizeCss(metadata.css.value);
|
||||
|
||||
const scoped = sanitizedCss
|
||||
.split(CSS_RULE_SPLIT_PATTERN)
|
||||
.map((rule) => {
|
||||
const trimmed = rule.trim();
|
||||
if (!trimmed || trimmed.startsWith("@")) return trimmed;
|
||||
|
||||
return trimmed.replace(CSS_SELECTOR_PATTERN, (_match, selectors, brace) => {
|
||||
const prefixed = selectors
|
||||
.split(",")
|
||||
.map((selector: string) => `.resume-preview-container ${selector.trim()} `)
|
||||
.join(", ");
|
||||
return `${prefixed}${brace}`;
|
||||
});
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return scoped;
|
||||
}, [metadata.css.enabled, metadata.css.value]);
|
||||
|
||||
const TemplateComponent = useMemo(() => getTemplateComponent(metadata.template), [metadata.template]);
|
||||
|
||||
return (
|
||||
<IconContext.Provider value={iconProps}>
|
||||
{/** biome-ignore lint/security/noDangerouslySetInnerHtml: CSS is sanitized with sanitizeCss */}
|
||||
{scopedCSS && <style dangerouslySetInnerHTML={{ __html: scopedCSS }} />}
|
||||
|
||||
<div style={style} className={cn("resume-preview-container", className)} {...props}>
|
||||
{metadata.layout.pages.map((pageLayout, pageIndex) => {
|
||||
const pageNumber = pageIndex + 1;
|
||||
|
||||
return (
|
||||
<div key={pageIndex} className="relative">
|
||||
{showPageNumbers && totalNumberOfPages > 1 && (
|
||||
<div className="absolute -top-6 left-0">
|
||||
<span className="font-medium text-foreground text-xs">
|
||||
<Trans>
|
||||
Page {pageNumber} of {totalNumberOfPages}
|
||||
</Trans>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
`page page-${pageIndex}`,
|
||||
pageIndex > 0 && "print:break-before-page",
|
||||
styles.page,
|
||||
pageClassName,
|
||||
)}
|
||||
>
|
||||
<TemplateComponent pageIndex={pageIndex} pageLayout={pageLayout} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</IconContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
import { match } from "ts-pattern";
|
||||
import type { SectionType } from "@/schema/resume/data";
|
||||
import { AwardsItem } from "./items/awards-item";
|
||||
import { CertificationsItem } from "./items/certifications-item";
|
||||
import { EducationItem } from "./items/education-item";
|
||||
import { ExperienceItem } from "./items/experience-item";
|
||||
import { InterestsItem } from "./items/interests-item";
|
||||
import { LanguagesItem } from "./items/languages-item";
|
||||
import { ProfilesItem } from "./items/profiles-item";
|
||||
import { ProjectsItem } from "./items/projects-item";
|
||||
import { PublicationsItem } from "./items/publications-item";
|
||||
import { ReferencesItem } from "./items/references-item";
|
||||
import { SkillsItem } from "./items/skills-item";
|
||||
import { VolunteerItem } from "./items/volunteer-item";
|
||||
import { PageCustomSection } from "./page-custom";
|
||||
import { PageSection } from "./page-section";
|
||||
import { PageSummary } from "./page-summary";
|
||||
|
||||
type SectionComponentProps = {
|
||||
sectionClassName?: string;
|
||||
itemClassName?: string;
|
||||
};
|
||||
|
||||
export function getSectionComponent(
|
||||
section: "summary" | SectionType | (string & {}),
|
||||
{ sectionClassName, itemClassName }: SectionComponentProps = {},
|
||||
) {
|
||||
return match(section)
|
||||
.with("summary", () => {
|
||||
const SummarySection = ({ id: _id }: { id: string }) => <PageSummary className={sectionClassName} />;
|
||||
return SummarySection;
|
||||
})
|
||||
.with("profiles", () => {
|
||||
const ProfilesSection = ({ id: _id }: { id: string }) => (
|
||||
<PageSection type="profiles" className={sectionClassName}>
|
||||
{(item) => <ProfilesItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
return ProfilesSection;
|
||||
})
|
||||
.with("experience", () => {
|
||||
const ExperienceSection = ({ id: _id }: { id: string }) => (
|
||||
<PageSection type="experience" className={sectionClassName}>
|
||||
{(item) => <ExperienceItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
return ExperienceSection;
|
||||
})
|
||||
.with("education", () => {
|
||||
const EducationSection = ({ id: _id }: { id: string }) => (
|
||||
<PageSection type="education" className={sectionClassName}>
|
||||
{(item) => <EducationItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
return EducationSection;
|
||||
})
|
||||
.with("projects", () => {
|
||||
const ProjectsSection = ({ id: _id }: { id: string }) => (
|
||||
<PageSection type="projects" className={sectionClassName}>
|
||||
{(item) => <ProjectsItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
return ProjectsSection;
|
||||
})
|
||||
.with("skills", () => {
|
||||
const SkillsSection = ({ id: _id }: { id: string }) => (
|
||||
<PageSection type="skills" className={sectionClassName}>
|
||||
{(item) => <SkillsItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
return SkillsSection;
|
||||
})
|
||||
.with("languages", () => {
|
||||
const LanguagesSection = ({ id: _id }: { id: string }) => (
|
||||
<PageSection type="languages" className={sectionClassName}>
|
||||
{(item) => <LanguagesItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
return LanguagesSection;
|
||||
})
|
||||
.with("interests", () => {
|
||||
const InterestsSection = ({ id: _id }: { id: string }) => (
|
||||
<PageSection type="interests" className={sectionClassName}>
|
||||
{(item) => <InterestsItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
return InterestsSection;
|
||||
})
|
||||
.with("awards", () => {
|
||||
const AwardsSection = ({ id: _id }: { id: string }) => (
|
||||
<PageSection type="awards" className={sectionClassName}>
|
||||
{(item) => <AwardsItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
return AwardsSection;
|
||||
})
|
||||
.with("certifications", () => {
|
||||
const CertificationsSection = ({ id: _id }: { id: string }) => (
|
||||
<PageSection type="certifications" className={sectionClassName}>
|
||||
{(item) => <CertificationsItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
return CertificationsSection;
|
||||
})
|
||||
.with("publications", () => {
|
||||
const PublicationsSection = ({ id: _id }: { id: string }) => (
|
||||
<PageSection type="publications" className={sectionClassName}>
|
||||
{(item) => <PublicationsItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
return PublicationsSection;
|
||||
})
|
||||
.with("volunteer", () => {
|
||||
const VolunteerSection = ({ id: _id }: { id: string }) => (
|
||||
<PageSection type="volunteer" className={sectionClassName}>
|
||||
{(item) => <VolunteerItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
return VolunteerSection;
|
||||
})
|
||||
.with("references", () => {
|
||||
const ReferencesSection = ({ id: _id }: { id: string }) => (
|
||||
<PageSection type="references" className={sectionClassName}>
|
||||
{(item) => <ReferencesItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
return ReferencesSection;
|
||||
})
|
||||
.otherwise(() => {
|
||||
const CustomSection = ({ id }: { id: string }) => (
|
||||
<PageCustomSection sectionId={id} className={sectionClassName} />
|
||||
);
|
||||
return CustomSection;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { TiptapContent } from "@/components/input/rich-input";
|
||||
import type { SectionItem } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { PageLink } from "../page-link";
|
||||
|
||||
type AwardsItemProps = SectionItem<"awards"> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AwardsItem({ className, ...item }: AwardsItemProps) {
|
||||
return (
|
||||
<div className={cn("awards-item", className)}>
|
||||
<div className="section-item-header awards-item-header">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="section-item-title awards-item-title">
|
||||
<strong>{item.title}</strong>
|
||||
</p>
|
||||
<p className="section-item-metadata text-right">{item.date}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="section-item-awarder awards-item-awarder">{item.awarder}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-item-description awards-item-description">
|
||||
<TiptapContent content={item.description} />
|
||||
</div>
|
||||
<div className="section-item-link awards-item-link">
|
||||
<PageLink {...item.website} label={item.website.label} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { TiptapContent } from "@/components/input/rich-input";
|
||||
import type { SectionItem } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { PageLink } from "../page-link";
|
||||
|
||||
type CertificationsItemProps = SectionItem<"certifications"> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function CertificationsItem({ className, ...item }: CertificationsItemProps) {
|
||||
return (
|
||||
<div className={cn("certifications-item", className)}>
|
||||
<div className="section-item-header certifications-item-header">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="section-item-title certifications-item-title">
|
||||
<strong>{item.title}</strong>
|
||||
</p>
|
||||
<p className="section-item-metadata text-right">{item.date}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="section-item-issuer certifications-item-issuer">{item.issuer}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-item-description certifications-item-description">
|
||||
<TiptapContent content={item.description} />
|
||||
</div>
|
||||
<div className="section-item-link certifications-item-link">
|
||||
<PageLink {...item.website} label={item.website.label} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { TiptapContent } from "@/components/input/rich-input";
|
||||
import type { SectionItem } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { PageLink } from "../page-link";
|
||||
|
||||
type EducationItemProps = SectionItem<"education"> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function EducationItem({ className, ...item }: EducationItemProps) {
|
||||
return (
|
||||
<div className={cn("education-item", className)}>
|
||||
<div className="section-item-header education-item-header mb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="section-item-title education-item-title">
|
||||
<strong>{item.school}</strong>
|
||||
</p>
|
||||
<p className="section-item-metadata education-item-metadata text-right">
|
||||
{[item.degree, item.grade].filter(Boolean).join(" • ")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p>{item.area}</p>
|
||||
<p className="section-item-metadata education-item-metadata text-right">
|
||||
{[item.location, item.period].filter(Boolean).join(" • ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-item-description education-item-description">
|
||||
<TiptapContent content={item.description} />
|
||||
</div>
|
||||
<div className="section-item-link education-item-link">
|
||||
<PageLink {...item.website} label={item.website.label} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { TiptapContent } from "@/components/input/rich-input";
|
||||
import type { SectionItem } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { PageLink } from "../page-link";
|
||||
|
||||
type ExperienceItemProps = SectionItem<"experience"> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ExperienceItem({ className, ...item }: ExperienceItemProps) {
|
||||
return (
|
||||
<div className={cn("experience-item", className)}>
|
||||
<div className="section-item-header experience-item-header">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="section-item-title experience-item-title">
|
||||
<strong>{item.company}</strong>
|
||||
</p>
|
||||
<p className="section-item-metadata experience-item-metadata text-right">{item.location}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p>{item.position}</p>
|
||||
<p className="section-item-metadata experience-item-metadata text-right">{item.period}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-item-description experience-item-description">
|
||||
<TiptapContent content={item.description} />
|
||||
</div>
|
||||
<div className="section-item-link experience-item-link">
|
||||
<PageLink {...item.website} label={item.website.label} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { SectionItem } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { PageIcon } from "../page-icon";
|
||||
|
||||
type InterestsItemProps = SectionItem<"interests"> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function InterestsItem({ className, ...item }: InterestsItemProps) {
|
||||
return (
|
||||
<div className={cn("interests-item", className)}>
|
||||
<div className="section-item-header flex items-center gap-x-1.5">
|
||||
<PageIcon icon={item.icon} className="section-item-icon interests-item-icon shrink-0" />
|
||||
<p className="section-item-name interests-item-name">
|
||||
<strong>{item.name}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="section-item-keywords interests-item-keywords opacity-80">{item.keywords.join(", ")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { SectionItem } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { PageLevel } from "../page-level";
|
||||
|
||||
type LanguagesItemProps = SectionItem<"languages"> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function LanguagesItem({ className, ...item }: LanguagesItemProps) {
|
||||
return (
|
||||
<div className={cn("languages-item", className)}>
|
||||
<div className="section-item-header">
|
||||
<p className="section-item-name languages-item-name">
|
||||
<strong>{item.language}</strong>
|
||||
</p>
|
||||
<p className="section-item-fluency languages-item-fluency opacity-80">{item.fluency}</p>
|
||||
</div>
|
||||
|
||||
<PageLevel level={item.level} className="section-item-level languages-item-level" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SectionItem } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { PageIcon } from "../page-icon";
|
||||
import { PageLink } from "../page-link";
|
||||
|
||||
type ProfilesItemProps = SectionItem<"profiles"> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ProfilesItem({ className, ...item }: ProfilesItemProps) {
|
||||
return (
|
||||
<div className={cn("profiles-item", className)}>
|
||||
<div className="section-item-header flex items-center gap-x-1.5">
|
||||
<PageIcon icon={item.icon} className="section-item-icon profiles-item-icon shrink-0" />
|
||||
<p className="section-item-network profiles-item-network">
|
||||
<strong>{item.network}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PageLink
|
||||
{...item.website}
|
||||
label={item.website.label || item.username}
|
||||
className="section-item-link profiles-item-link"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { TiptapContent } from "@/components/input/rich-input";
|
||||
import type { SectionItem } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { PageLink } from "../page-link";
|
||||
|
||||
type ProjectsItemProps = SectionItem<"projects"> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ProjectsItem({ className, ...item }: ProjectsItemProps) {
|
||||
return (
|
||||
<div className={cn("projects-item", className)}>
|
||||
<div className="section-item-header projects-item-header">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="section-item-title projects-item-title">
|
||||
<strong>{item.name}</strong>
|
||||
</p>
|
||||
<p className="section-item-metadata text-right">{item.period}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-item-description projects-item-description">
|
||||
<TiptapContent content={item.description} />
|
||||
</div>
|
||||
<div className="section-item-link projects-item-link">
|
||||
<PageLink {...item.website} label={item.website.label} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { TiptapContent } from "@/components/input/rich-input";
|
||||
import type { SectionItem } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { PageLink } from "../page-link";
|
||||
|
||||
type PublicationsItemProps = SectionItem<"publications"> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PublicationsItem({ className, ...item }: PublicationsItemProps) {
|
||||
return (
|
||||
<div className={cn("publications-item", className)}>
|
||||
<div className="section-item-header publications-item-header">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="section-item-title publications-item-title">
|
||||
<strong>{item.title}</strong>
|
||||
</p>
|
||||
<p className="section-item-metadata text-right">{item.date}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="section-item-publisher publications-item-publisher">{item.publisher}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-item-description publications-item-description">
|
||||
<TiptapContent content={item.description} />
|
||||
</div>
|
||||
<div className="section-item-link publications-item-link">
|
||||
<PageLink {...item.website} label={item.website.label} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TiptapContent } from "@/components/input/rich-input";
|
||||
import type { SectionItem } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type ReferencesItemProps = SectionItem<"references"> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ReferencesItem({ className, ...item }: ReferencesItemProps) {
|
||||
return (
|
||||
<div className={cn("references-item", className)}>
|
||||
<p className="section-item-name references-item-name">
|
||||
<strong>{item.name}</strong>
|
||||
</p>
|
||||
<div className="section-item-description references-item-description">
|
||||
<TiptapContent content={item.description} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { SectionItem } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { PageIcon } from "../page-icon";
|
||||
import { PageLevel } from "../page-level";
|
||||
|
||||
type SkillsItemProps = SectionItem<"skills"> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function SkillsItem({ className, ...item }: SkillsItemProps) {
|
||||
return (
|
||||
<div className={cn("skills-item", className)}>
|
||||
<div className="section-item-header flex items-center gap-x-1.5">
|
||||
<PageIcon icon={item.icon} className="section-item-icon skills-item-icon shrink-0" />
|
||||
<p className="section-item-name skills-item-name">
|
||||
<strong>{item.name}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="section-item-proficiency skills-item-proficiency opacity-80">{item.proficiency}</p>
|
||||
<small className="section-item-keywords skills-item-keywords">{item.keywords.join(", ")}</small>
|
||||
</div>
|
||||
|
||||
<PageLevel level={item.level} className="section-item-level skills-item-level" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { TiptapContent } from "@/components/input/rich-input";
|
||||
import type { SectionItem } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { PageLink } from "../page-link";
|
||||
|
||||
type VolunteerItemProps = SectionItem<"volunteer"> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function VolunteerItem({ className, ...item }: VolunteerItemProps) {
|
||||
return (
|
||||
<div className={cn("volunteer-item", className)}>
|
||||
<div className="section-item-header volunteer-item-header">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="section-item-title volunteer-item-title">
|
||||
<strong>{item.organization}</strong>
|
||||
</p>
|
||||
<p className="section-item-metadata text-right">{item.period}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="section-item-location volunteer-item-location">{item.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-item-description volunteer-item-description">
|
||||
<TiptapContent content={item.description} />
|
||||
</div>
|
||||
<div className="section-item-link volunteer-item-link">
|
||||
<PageLink {...item.website} label={item.website.label} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { TiptapContent } from "@/components/input/rich-input";
|
||||
import { cn } from "@/utils/style";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
|
||||
type PageCustomSectionProps = {
|
||||
sectionId: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PageCustomSection({ sectionId, className }: PageCustomSectionProps) {
|
||||
const section = useResumeStore((state) =>
|
||||
state.resume.data.customSections.find((section) => section.id === sectionId),
|
||||
);
|
||||
|
||||
// biome-ignore lint/complexity/noUselessFragments: render empty fragment, instead of null
|
||||
if (!section) return <></>;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
`page-section page-section-custom page-section-${sectionId} wrap-break-word`,
|
||||
section.hidden && "hidden",
|
||||
section.content === "" && "hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<h6 className="mb-2 text-(--page-primary-color)">{section.title}</h6>
|
||||
|
||||
<div className="section-content">
|
||||
<TiptapContent style={{ columnCount: section.columns }} content={section.content} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { IconContext } from "@phosphor-icons/react";
|
||||
import { use } from "react";
|
||||
import { cn } from "@/utils/style";
|
||||
import type { ExtendedIconProps } from "../preview";
|
||||
|
||||
export function PageIcon({ icon, className }: { icon: string; className?: string }) {
|
||||
const iconContext = use<ExtendedIconProps>(IconContext);
|
||||
|
||||
if (!icon || iconContext.hidden) return null;
|
||||
|
||||
return (
|
||||
<i
|
||||
className={cn("ph", `ph-${icon}`, className)}
|
||||
style={{ fontSize: `${iconContext.size}px`, color: iconContext.color }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { LevelDisplay } from "@/components/level/display";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
|
||||
type Props = React.ComponentProps<"div"> & {
|
||||
level: number;
|
||||
};
|
||||
|
||||
export function PageLevel({ level, className, ...props }: Props) {
|
||||
const { icon, type } = useResumeStore((state) => state.resume.data.metadata.design.level);
|
||||
|
||||
return <LevelDisplay icon={icon} type={type} level={level} className={className} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type Props = {
|
||||
url: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PageLink({ url, label, className }: Props) {
|
||||
if (!url) return null;
|
||||
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className={cn("block truncate", className)}>
|
||||
{label || url}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cn } from "@/utils/style";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
|
||||
export function PagePicture({ className, style }: { className?: string; style?: React.CSSProperties }) {
|
||||
const name = useResumeStore((state) => state.resume.data.basics.name);
|
||||
const picture = useResumeStore((state) => state.resume.data.picture);
|
||||
|
||||
if (picture.url === "") return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("page-picture shrink-0 overflow-hidden", picture.hidden && "hidden", className)}
|
||||
style={{
|
||||
maxWidth: `${picture.size}pt`,
|
||||
maxHeight: `${picture.size}pt`,
|
||||
aspectRatio: picture.aspectRatio,
|
||||
borderRadius: `${picture.borderRadius}%`,
|
||||
border: picture.borderWidth > 0 ? `${picture.borderWidth}pt solid ${picture.borderColor}` : "none",
|
||||
boxShadow: picture.shadowWidth > 0 ? `0 0 ${picture.shadowWidth}pt 0 ${picture.shadowColor}` : "none",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt={name}
|
||||
src={picture.url}
|
||||
className="size-full object-cover"
|
||||
style={{ transform: `rotate(${picture.rotation}deg)` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { SectionItem, SectionType } from "@/schema/resume/data";
|
||||
import { getSectionTitle } from "@/utils/resume/section";
|
||||
import { cn } from "@/utils/style";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
|
||||
type PageSectionProps<T extends SectionType> = {
|
||||
type: T;
|
||||
className?: string;
|
||||
children: (item: SectionItem<T>) => React.ReactNode;
|
||||
};
|
||||
|
||||
export function PageSection<T extends SectionType>({ type, className, children }: PageSectionProps<T>) {
|
||||
const section = useResumeStore((state) => state.resume.data.sections[type]);
|
||||
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
|
||||
if (section.hidden) return null;
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className={cn(`page-section page-section-${type}`, className)}>
|
||||
<h6 className="mb-1 text-(--page-primary-color)">{section.title || getSectionTitle(type)}</h6>
|
||||
|
||||
<ul
|
||||
className="section-content grid gap-x-(--page-gap-x) gap-y-(--page-gap-y)"
|
||||
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className={cn(`section-item section-item-${type} wrap-break-word *:space-y-1`)}>
|
||||
{children(item)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { TiptapContent } from "@/components/input/rich-input";
|
||||
import { getSectionTitle } from "@/utils/resume/section";
|
||||
import { cn } from "@/utils/style";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
|
||||
type PageSummaryProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PageSummary({ className }: PageSummaryProps) {
|
||||
const section = useResumeStore((state) => state.resume.data.summary);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"page-section page-section-summary wrap-break-word",
|
||||
section.hidden && "hidden",
|
||||
section.content === "" && "hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<h6 className="mb-2 text-(--page-primary-color)">{section.title || getSectionTitle("summary")}</h6>
|
||||
|
||||
<div className="section-content">
|
||||
<TiptapContent style={{ columnCount: section.columns }} content={section.content} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { debounce } from "es-toolkit";
|
||||
import type { WritableDraft } from "immer";
|
||||
import { current } from "immer";
|
||||
import { toast } from "sonner";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
import { create } from "zustand/react";
|
||||
import { orpc, type RouterOutput } from "@/integrations/orpc/client";
|
||||
import type { ResumeData } from "@/schema/resume/data";
|
||||
|
||||
type Resume = Omit<RouterOutput["resume"]["getById"], "hasPassword">;
|
||||
|
||||
type ResumeStoreState = {
|
||||
resume: Resume;
|
||||
isReady: boolean;
|
||||
};
|
||||
|
||||
type ResumeStoreActions = {
|
||||
initialize: (resume: Resume | null) => void;
|
||||
updateResumeData: (fn: (draft: WritableDraft<ResumeData>) => void) => void;
|
||||
};
|
||||
|
||||
type ResumeStore = ResumeStoreState & ResumeStoreActions;
|
||||
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
|
||||
const _syncResume = async (resume: Resume | null) => {
|
||||
if (!resume) return;
|
||||
await orpc.resume.update.call({ id: resume.id, data: resume.data }, { signal });
|
||||
};
|
||||
|
||||
const syncResume = debounce(_syncResume, 500, { signal });
|
||||
|
||||
let errorToastId: string | number | undefined;
|
||||
|
||||
export const useResumeStore = create<ResumeStore>()(
|
||||
immer((set) => ({
|
||||
resume: null as unknown as Resume,
|
||||
isReady: false,
|
||||
|
||||
initialize: (resume) => {
|
||||
set((state) => {
|
||||
state.resume = resume as Resume;
|
||||
state.isReady = resume !== null;
|
||||
});
|
||||
},
|
||||
|
||||
updateResumeData: (fn) => {
|
||||
set((state) => {
|
||||
if (!state.resume) return state;
|
||||
if (state.resume.isLocked) {
|
||||
errorToastId = toast.error(t`This resume is locked and cannot be updated.`, { id: errorToastId });
|
||||
return state;
|
||||
}
|
||||
|
||||
fn(state.resume.data as WritableDraft<ResumeData>);
|
||||
syncResume(current(state.resume));
|
||||
});
|
||||
},
|
||||
})),
|
||||
);
|
||||
@@ -0,0 +1,123 @@
|
||||
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/utils/style";
|
||||
import { getSectionComponent } from "../shared/get-section-component";
|
||||
import { PageIcon } from "../shared/page-icon";
|
||||
import { PageLink } from "../shared/page-link";
|
||||
import { PagePicture } from "../shared/page-picture";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
import type { TemplateProps } from "./types";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Section in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
|
||||
|
||||
// Section in Main Layout
|
||||
"group-data-[layout=main]:[&>.section-content]:relative",
|
||||
"group-data-[layout=main]:[&>.section-content]:ml-4",
|
||||
"group-data-[layout=main]:[&>.section-content]:pl-4",
|
||||
"group-data-[layout=main]:[&>.section-content]:border-l",
|
||||
"group-data-[layout=main]:[&>.section-content]:border-(--page-primary-color)",
|
||||
|
||||
// Timeline Marker in Main Layout
|
||||
"group-data-[layout=main]:[&>.section-content]:after:content-['']",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:absolute",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:top-5",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:left-0",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:size-2.5",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:translate-x-[-50%]",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:translate-y-[-50%]",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:rounded-full",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:border",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:border-(--page-primary-color)",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:bg-(--page-background-color)",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Azurill
|
||||
*/
|
||||
export function AzurillTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-azurill page-content space-y-(--page-gap-y) px-(--page-margin-x) py-(--page-margin-y) print:p-0">
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<div className="flex gap-x-(--page-gap-x)">
|
||||
{!fullWidth && (
|
||||
<aside
|
||||
data-layout="sidebar"
|
||||
className="group page-sidebar w-(--page-sidebar-width) shrink-0 space-y-(--page-gap-y) overflow-x-hidden"
|
||||
>
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<main data-layout="main" className="group page-main grow space-y-(--page-gap-y)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header flex flex-col items-center gap-y-2">
|
||||
<PagePicture />
|
||||
|
||||
<div className="page-basics space-y-2 text-center">
|
||||
<div className="basics-header">
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div className="basics-items flex flex-wrap justify-center gap-x-3 gap-y-1 *:flex *:items-center *:gap-x-1.5">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
<span>{field.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/utils/style";
|
||||
import { getSectionComponent } from "../shared/get-section-component";
|
||||
import { PageIcon } from "../shared/page-icon";
|
||||
import { PageLink } from "../shared/page-link";
|
||||
import { PagePicture } from "../shared/page-picture";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
import type { TemplateProps } from "./types";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Section Layout
|
||||
"grid grid-cols-5 border-(--page-primary-color) border-t pt-1",
|
||||
|
||||
// Section Content
|
||||
"[&>.section-content]:col-span-4",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Bronzor
|
||||
*/
|
||||
export function BronzorTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-bronzor page-content space-y-(--page-gap-y) px-(--page-margin-x) py-(--page-margin-y) print:p-0">
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<div className="space-y-(--page-gap-y)">
|
||||
<main data-layout="main" className="group page-main space-y-(--page-gap-y)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
|
||||
{!fullWidth && (
|
||||
<aside data-layout="sidebar" className="group page-sidebar space-y-(--page-gap-y)">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header flex flex-col items-center gap-y-2">
|
||||
<PagePicture />
|
||||
|
||||
<div className="page-basics space-y-2 text-center">
|
||||
<div className="basics-header">
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div className="basics-items flex flex-wrap justify-center gap-x-3 gap-y-1 text-center *:flex *:items-center *:gap-x-1.5">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
<span>{field.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/utils/style";
|
||||
import { getSectionComponent } from "../shared/get-section-component";
|
||||
import { PageIcon } from "../shared/page-icon";
|
||||
import { PageLink } from "../shared/page-link";
|
||||
import { PagePicture } from "../shared/page-picture";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
import type { TemplateProps } from "./types";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Section Heading
|
||||
"[&>h6]:border-(--page-primary-color) [&>h6]:border-b",
|
||||
|
||||
// Section Heading in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&>h6]:text-(--page-background-color)",
|
||||
"group-data-[layout=sidebar]:[&>h6]:border-(--page-background-color)",
|
||||
|
||||
// Icon Colors in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item_i]:text-(--page-background-color)!",
|
||||
|
||||
// Section Item Header in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Chikorita
|
||||
*/
|
||||
export function ChikoritaTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-chikorita page-content">
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<div className="flex">
|
||||
<main data-layout="main" className="group page-main flex-1 space-y-4 px-(--page-margin-x) py-(--page-margin-y)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
|
||||
{!fullWidth && (
|
||||
<aside
|
||||
data-layout="sidebar"
|
||||
className="group page-sidebar w-(--page-sidebar-width) shrink-0 space-y-4 overflow-x-hidden bg-(--page-primary-color) px-(--page-margin-x) pb-(--page-margin-y) text-(--page-background-color)"
|
||||
>
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header relative flex">
|
||||
<div className="flex items-center pt-(--page-margin-y) pl-(--page-margin-x)">
|
||||
<PagePicture />
|
||||
|
||||
<div className="page-basics space-y-2 px-(--page-margin-x)">
|
||||
<div>
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div className="basics-items flex flex-wrap gap-x-2 gap-y-0.5 *:flex *:items-center *:gap-x-1.5">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
<span>{field.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-(--page-sidebar-width) shrink-0 bg-(--page-primary-color)" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { cn } from "@/utils/style";
|
||||
import { getSectionComponent } from "../shared/get-section-component";
|
||||
import { PageIcon } from "../shared/page-icon";
|
||||
import { PageLink } from "../shared/page-link";
|
||||
import { PagePicture } from "../shared/page-picture";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
import type { TemplateProps } from "./types";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Section Heading
|
||||
"[&>h6]:border-(--page-primary-color) [&>h6]:border-b",
|
||||
|
||||
// Icon Colors in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item_i]:text-(--page-background-color)!",
|
||||
|
||||
// Section Item Header in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Ditgar
|
||||
*/
|
||||
export function DitgarTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-ditgar page-content grid min-h-[inherit] grid-cols-3">
|
||||
<div className={cn("sidebar group flex flex-col", !(isFirstPage || !fullWidth) && "hidden")}>
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<div className="flex-1 space-y-4 bg-(--page-primary-color)/20 px-(--page-margin-x) py-(--page-margin-y)">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn("main group", !fullWidth ? "col-span-2" : "col-span-3")}>
|
||||
{isFirstPage &&
|
||||
(() => {
|
||||
const SummaryComponent = getSectionComponent("summary", { sectionClassName });
|
||||
return <SummaryComponent id="summary" />;
|
||||
})()}
|
||||
|
||||
<div className="space-y-4 px-(--page-margin-x) py-(--page-margin-y)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header space-y-4 bg-(--page-primary-color) px-(--page-margin-x) py-(--page-margin-y) text-(--page-background-color)">
|
||||
<PagePicture />
|
||||
|
||||
<div>
|
||||
<h2 className="font-bold text-2xl">{basics.name}</h2>
|
||||
<p>{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-y-2 text-sm">
|
||||
{basics.location && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<PageIcon icon="map-pin" className="ph-bold" />
|
||||
<div>{basics.location}</div>
|
||||
</div>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<PageIcon icon="phone" className="ph-bold" />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
{basics.email && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<PageIcon icon="at" className="ph-bold" />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<PageIcon icon="globe" className="ph-bold" />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/utils/style";
|
||||
import { getSectionComponent } from "../shared/get-section-component";
|
||||
import { PageIcon } from "../shared/page-icon";
|
||||
import { PageLink } from "../shared/page-link";
|
||||
import { PagePicture } from "../shared/page-picture";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
import type { TemplateProps } from "./types";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Section Item Header in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Ditto
|
||||
*/
|
||||
export function DittoTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-ditto page-content">
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<div className="flex py-(--page-margin-y)">
|
||||
{!fullWidth && (
|
||||
<aside
|
||||
data-layout="sidebar"
|
||||
className="group page-sidebar w-(--page-sidebar-width) shrink-0 space-y-4 overflow-x-hidden pl-(--page-margin-x)"
|
||||
>
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<main data-layout="main" className="group page-main space-y-4 px-(--page-margin-x)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header relative">
|
||||
<div className="page-basics bg-(--page-primary-color) text-(--page-background-color)">
|
||||
<div className="basics-header flex items-center">
|
||||
<div className="flex w-(--page-sidebar-width) shrink-0 justify-center pl-(--page-margin-x)">
|
||||
<PagePicture className="absolute top-8" />
|
||||
</div>
|
||||
|
||||
<div className="px-(--page-margin-x) py-(--page-margin-y)">
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<div className="w-(--page-sidebar-width) shrink-0" />
|
||||
|
||||
<div className="basics-items flex flex-wrap gap-x-3 gap-y-1 px-(--page-margin-x) pt-3 *:flex *:items-center *:gap-x-1.5">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
<span>{field.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { cn } from "@/utils/style";
|
||||
import { getSectionComponent } from "../shared/get-section-component";
|
||||
import { PageIcon } from "../shared/page-icon";
|
||||
import { PageLink } from "../shared/page-link";
|
||||
import { PagePicture } from "../shared/page-picture";
|
||||
import { PageSummary } from "../shared/page-summary";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
import type { TemplateProps } from "./types";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Section Heading
|
||||
"[&>h6]:border-(--page-primary-color) [&>h6]:border-b",
|
||||
|
||||
// Section Item Header in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Gengar
|
||||
*/
|
||||
export function GengarTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-gengar page-content">
|
||||
<div className="flex">
|
||||
<div data-layout="sidebar" className="group page-sidebar flex w-(--page-sidebar-width) shrink-0 flex-col">
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
{!fullWidth && (
|
||||
<aside className="shrink-0 space-y-4 overflow-x-hidden bg-(--page-primary-color)/20 px-(--page-margin-x) pt-4 pb-(--page-margin-y)">
|
||||
{sidebar
|
||||
.filter((section) => section !== "summary")
|
||||
.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<main data-layout="main" className="group page-main">
|
||||
{isFirstPage && (
|
||||
<PageSummary
|
||||
className={cn(
|
||||
sectionClassName,
|
||||
"bg-(--page-primary-color)/20 px-(--page-margin-x) py-(--page-margin-y) [&>h6]:hidden",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 px-(--page-margin-x) pt-4 pb-(--page-margin-y)">
|
||||
{main
|
||||
.filter((section) => section !== "summary")
|
||||
.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header relative flex">
|
||||
<div className="flex w-full shrink-0 flex-col justify-center gap-y-2 bg-(--page-primary-color) px-(--page-margin-x) py-(--page-margin-y) text-(--page-background-color)">
|
||||
<PagePicture />
|
||||
|
||||
<div>
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="basics-items flex flex-col gap-y-1 *:flex *:items-center *:gap-x-1.5"
|
||||
style={{ "--page-primary-color": "var(--page-background-color)" } as React.CSSProperties}
|
||||
>
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<PageIcon icon="envelope" />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PageIcon icon="phone" />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<PageIcon icon="map-pin" />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<PageIcon icon="globe" />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
<span>{field.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/utils/style";
|
||||
import { getSectionComponent } from "../shared/get-section-component";
|
||||
import { PageIcon } from "../shared/page-icon";
|
||||
import { PageLink } from "../shared/page-link";
|
||||
import { PagePicture } from "../shared/page-picture";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
import type { TemplateProps } from "./types";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Section Heading
|
||||
"[&>h6]:border-(--page-primary-color) [&>h6]:border-b",
|
||||
|
||||
// Section Item Header in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Glalie
|
||||
*/
|
||||
export function GlalieTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-glalie page-content">
|
||||
<div className="flex">
|
||||
<aside
|
||||
data-layout="sidebar"
|
||||
className="group page-sidebar flex w-(--page-sidebar-width) shrink-0 flex-col space-y-4 bg-(--page-primary-color)/20 px-(--page-margin-x) py-(--page-margin-y)"
|
||||
>
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
{!fullWidth && (
|
||||
<div className="shrink-0 space-y-4 overflow-x-hidden">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main data-layout="main" className="group page-main">
|
||||
<div className="space-y-4 px-(--page-margin-x) py-(--page-margin-y)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header relative flex">
|
||||
<div className="flex w-full shrink-0 flex-col items-center justify-center gap-y-2">
|
||||
<PagePicture />
|
||||
|
||||
<div className="text-center">
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{ "--box-radius": "calc(var(--picture-border-radius) / 4)" } as React.CSSProperties}
|
||||
className="basics-items flex w-full flex-col gap-y-1 rounded-(--box-radius) border border-(--page-primary-color) p-3 *:flex *:items-center *:gap-x-1.5"
|
||||
>
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
<span>{field.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/utils/style";
|
||||
import { getSectionComponent } from "../shared/get-section-component";
|
||||
import { PageIcon } from "../shared/page-icon";
|
||||
import { PageLink } from "../shared/page-link";
|
||||
import { PagePicture } from "../shared/page-picture";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
import type { TemplateProps } from "./types";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Section Heading
|
||||
"[&>h6]:border-(--page-primary-color) [&>h6]:border-b [&>h6]:pb-0.5 [&>h6]:text-center",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Kakuna
|
||||
*/
|
||||
export function KakunaTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-kakuna page-content space-y-4 px-(--page-margin-x) py-(--page-margin-y) print:p-0">
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<main data-layout="main" className="group page-main space-y-4">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
|
||||
{!fullWidth && (
|
||||
<aside data-layout="sidebar" className="group page-sidebar space-y-4">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header flex flex-col items-center gap-y-2">
|
||||
<PagePicture />
|
||||
|
||||
<div className="page-basics space-y-2 text-center">
|
||||
<div>
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div className="basics-items flex flex-wrap justify-center gap-x-3 gap-y-0.5 *:flex *:items-center *:gap-x-1.5">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
<span>{field.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/utils/style";
|
||||
import { getSectionComponent } from "../shared/get-section-component";
|
||||
import { PageIcon } from "../shared/page-icon";
|
||||
import { PageLink } from "../shared/page-link";
|
||||
import { PagePicture } from "../shared/page-picture";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
import type { TemplateProps } from "./types";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Container
|
||||
"rounded-(--container-border-radius) border border-(--page-text-color)/10 bg-(--page-background-color) p-4 shadow-md",
|
||||
|
||||
// Section Heading
|
||||
"[&>h6]:-mt-(--heading-negative-margin) [&>h6]:max-w-fit [&>h6]:bg-(--page-background-color) [&>h6]:px-4 [&>h6]:pb-0.5",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Lapras
|
||||
*/
|
||||
export function LaprasTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
const containerBorderRadius = useResumeStore((state) => Math.min(state.resume.data.picture.borderRadius, 30));
|
||||
const headingNegativeMargin = useResumeStore((state) => state.resume.data.metadata.typography.heading.fontSize + 6);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--container-border-radius": `${containerBorderRadius}pt`,
|
||||
"--heading-negative-margin": `${headingNegativeMargin}pt`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className="template-lapras page-content space-y-7 px-(--page-margin-x) py-(--page-margin-y) print:p-0"
|
||||
>
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<main data-layout="main" className="group page-main space-y-7">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
|
||||
{!fullWidth && (
|
||||
<aside data-layout="sidebar" className="group page-sidebar space-y-7">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"page-header flex items-center gap-x-4",
|
||||
"rounded-(--picture-border-radius) border border-(--page-text-color)/10 bg-(--page-background-color) p-4 shadow-md",
|
||||
)}
|
||||
>
|
||||
<PagePicture />
|
||||
|
||||
<div className="page-basics space-y-2">
|
||||
<div>
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div className="basics-items flex flex-wrap gap-x-2 gap-y-0.5 *:flex *:items-center *:gap-x-1.5 *:border-(--page-primary-color) *:border-r *:py-0.5 *:pr-2 *:last:border-r-0">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
<span>{field.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/utils/style";
|
||||
import { getSectionComponent } from "../shared/get-section-component";
|
||||
import { PageIcon } from "../shared/page-icon";
|
||||
import { PageLink } from "../shared/page-link";
|
||||
import { PagePicture } from "../shared/page-picture";
|
||||
import { PageSummary } from "../shared/page-summary";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
import type { TemplateProps } from "./types";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Section Heading
|
||||
"[&>h6]:border-(--page-primary-color) [&>h6]:border-b",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Leafish
|
||||
*/
|
||||
export function LeafishTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-leafish page-content space-y-4">
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<div className="flex gap-x-(--page-margin-x) px-(--page-margin-x) pb-(--page-margin-y)">
|
||||
<main data-layout="main" className="group page-main space-y-4">
|
||||
{main
|
||||
.filter((section) => section !== "summary")
|
||||
.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
|
||||
{!fullWidth && (
|
||||
<aside data-layout="sidebar" className="group page-sidebar w-(--page-sidebar-width) shrink-0 space-y-4">
|
||||
{sidebar
|
||||
.filter((section) => section !== "summary")
|
||||
.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header bg-(--page-primary-color)/10">
|
||||
<div className="flex items-center gap-x-(--page-margin-x) px-(--page-margin-x) py-(--page-margin-y)">
|
||||
<PagePicture />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<PageSummary className="[&>h6]:hidden" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-basics bg-(--page-primary-color)/10 px-(--page-margin-x) py-(--page-margin-y)">
|
||||
<div className="basics-items flex flex-wrap gap-x-4 gap-y-1 *:flex *:items-center *:gap-x-1.5">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
<span>{field.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/utils/style";
|
||||
import { getSectionComponent } from "../shared/get-section-component";
|
||||
import { PageIcon } from "../shared/page-icon";
|
||||
import { PageLink } from "../shared/page-link";
|
||||
import { PagePicture } from "../shared/page-picture";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
import type { TemplateProps } from "./types";
|
||||
|
||||
const sectionClassName = cn();
|
||||
|
||||
/**
|
||||
* Template: Onyx
|
||||
*/
|
||||
export function OnyxTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-onyx page-content space-y-(--page-gap-y) px-(--page-margin-x) py-(--page-margin-y) print:p-0">
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<main data-layout="main" className="group page-main space-y-(--page-gap-y)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
|
||||
{!fullWidth && (
|
||||
<aside data-layout="sidebar" className="group page-sidebar space-y-(--page-gap-y)">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header flex items-center gap-x-4 border-(--page-primary-color) border-b pb-(--page-gap-y)">
|
||||
<PagePicture />
|
||||
|
||||
<div className="page-basics space-y-2">
|
||||
<div>
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div className="basics-items flex flex-wrap gap-x-3 gap-y-0.5 *:flex *:items-center *:gap-x-1.5">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
<span>{field.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/utils/style";
|
||||
import { getSectionComponent } from "../shared/get-section-component";
|
||||
import { PageIcon } from "../shared/page-icon";
|
||||
import { PageLink } from "../shared/page-link";
|
||||
import { PagePicture } from "../shared/page-picture";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
import type { TemplateProps } from "./types";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Section Heading
|
||||
"[&>h6]:border-(--page-primary-color) [&>h6]:border-b",
|
||||
|
||||
// Section Item Header in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Pikachu
|
||||
*/
|
||||
export function PikachuTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-pikachu page-content px-(--page-margin-x) py-(--page-margin-y) print:p-0">
|
||||
<div className="flex gap-x-(--page-margin-x)">
|
||||
<aside
|
||||
data-layout="sidebar"
|
||||
className="group page-sidebar flex w-(--page-sidebar-width) shrink-0 flex-col space-y-3"
|
||||
>
|
||||
{isFirstPage && <PagePicture className="max-h-full! max-w-full!" />}
|
||||
|
||||
{!fullWidth && (
|
||||
<div className="shrink-0 space-y-4 overflow-x-hidden">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main data-layout="main" className="group page-main space-y-3">
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<div className="space-y-4 pb-(--page-margin-y)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header w-full space-y-2 rounded-(--picture-border-radius) bg-(--page-primary-color) px-(--page-margin-x) py-(--page-margin-y) text-(--page-background-color)">
|
||||
<div className="border-(--page-background-color)/50 border-b pb-2">
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="basics-items flex flex-wrap gap-x-3 gap-y-0.5 *:flex *:items-center *:gap-x-1.5"
|
||||
style={{ "--page-primary-color": "var(--page-background-color)" } as React.CSSProperties}
|
||||
>
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
<span>{field.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/utils/style";
|
||||
import { getSectionComponent } from "../shared/get-section-component";
|
||||
import { PageIcon } from "../shared/page-icon";
|
||||
import { PageLink } from "../shared/page-link";
|
||||
import { PagePicture } from "../shared/page-picture";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
import type { TemplateProps } from "./types";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Section Heading
|
||||
"[&>h6]:border-(--page-primary-color) [&>h6]:border-b",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Rhyhorn
|
||||
*/
|
||||
export function RhyhornTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-rhyhorn page-content space-y-4 px-(--page-margin-x) py-(--page-margin-y) print:p-0">
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<main data-layout="main" className="group page-main space-y-4">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
|
||||
{!fullWidth && (
|
||||
<aside data-layout="sidebar" className="group page-sidebar space-y-4">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header flex items-center gap-x-4">
|
||||
<div className="page-basics grow space-y-2">
|
||||
<div>
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div className="basics-items flex flex-wrap gap-x-2 gap-y-0.5 *:flex *:items-center *:gap-x-1.5 *:border-(--page-primary-color) *:border-r *:py-0.5 *:pr-2 *:last:border-r-0">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
<span>{field.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PagePicture />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type z from "zod";
|
||||
import type { pageLayoutSchema } from "@/schema/resume/data";
|
||||
|
||||
export type TemplateProps = {
|
||||
pageIndex: number;
|
||||
pageLayout: z.infer<typeof pageLayoutSchema>;
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { isTheme, setThemeServerFn, themeMap } from "@/utils/theme";
|
||||
import { Combobox, type ComboboxProps } from "../ui/combobox";
|
||||
import { useTheme } from "./provider";
|
||||
|
||||
type Props = Omit<ComboboxProps, "options" | "value" | "onValueChange">;
|
||||
|
||||
export function ThemeCombobox(props: Props) {
|
||||
const router = useRouter();
|
||||
const { i18n } = useLingui();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const options = Object.entries(themeMap).map(([value, label]) => ({
|
||||
value,
|
||||
label: i18n.t(label),
|
||||
keywords: [i18n.t(label)],
|
||||
}));
|
||||
|
||||
const onThemeChange = async (value: string | null) => {
|
||||
if (!value || !isTheme(value)) return;
|
||||
await setThemeServerFn({ data: value });
|
||||
setTheme(value);
|
||||
router.invalidate();
|
||||
};
|
||||
|
||||
return <Combobox options={options} defaultValue={theme} onValueChange={onThemeChange} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { createContext, type PropsWithChildren, use } from "react";
|
||||
import { setThemeServerFn, type Theme } from "@/utils/theme";
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: Theme;
|
||||
setTheme: (value: Theme, options?: { playSound?: boolean }) => void;
|
||||
toggleTheme: (options?: { playSound?: boolean }) => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
type Props = PropsWithChildren<{ theme: Theme }>;
|
||||
|
||||
export function ThemeProvider({ children, theme }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
async function setTheme(value: Theme, options: { playSound?: boolean } = {}) {
|
||||
const { playSound = true } = options;
|
||||
|
||||
document.documentElement.classList.toggle("dark", value === "dark");
|
||||
await setThemeServerFn({ data: value });
|
||||
router.invalidate();
|
||||
|
||||
if (!playSound) return;
|
||||
|
||||
try {
|
||||
const soundClip = value === "dark" ? "/sounds/switch-off.mp3" : "/sounds/switch-on.mp3";
|
||||
const audio = new Audio(soundClip);
|
||||
await audio.play();
|
||||
} catch {
|
||||
// ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme(options: { playSound?: boolean } = {}) {
|
||||
setTheme(theme === "dark" ? "light" : "dark", options);
|
||||
}
|
||||
|
||||
return <ThemeContext value={{ theme, setTheme, toggleTheme }}>{children}</ThemeContext>;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const value = use(ThemeContext);
|
||||
|
||||
if (!value) throw new Error("useTheme must be used within a ThemeProvider");
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { MoonIcon, SunIcon } from "@phosphor-icons/react";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { useTheme } from "./provider";
|
||||
|
||||
export function ThemeToggleButton(props: React.ComponentProps<typeof Button>) {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const onToggleTheme = useCallback(async () => {
|
||||
if (
|
||||
!buttonRef.current ||
|
||||
!document.startViewTransition ||
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
) {
|
||||
toggleTheme();
|
||||
return;
|
||||
}
|
||||
|
||||
let timeout: NodeJS.Timeout;
|
||||
const style = document.createElement("style");
|
||||
|
||||
style.textContent = `
|
||||
::view-transition-old(root), ::view-transition-new(root) {
|
||||
mix-blend-mode: normal !important;
|
||||
animation: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
function transitionCallback() {
|
||||
flushSync(() => {
|
||||
toggleTheme();
|
||||
timeout = setTimeout(() => {
|
||||
clearTimeout(timeout);
|
||||
document.head.removeChild(style);
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
document.head.appendChild(style);
|
||||
await document.startViewTransition(transitionCallback).ready;
|
||||
|
||||
const { top, left, width, height } = buttonRef.current.getBoundingClientRect();
|
||||
const x = left + width / 2;
|
||||
const y = top + height / 2;
|
||||
const right = window.innerWidth - left;
|
||||
const bottom = window.innerHeight - top;
|
||||
const maxRadius = Math.hypot(Math.max(left, right), Math.max(top, bottom));
|
||||
|
||||
document.documentElement.animate(
|
||||
{ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${maxRadius}px at ${x}px ${y}px)`] },
|
||||
{ duration: 500, easing: "ease-in-out", pseudoElement: "::view-transition-new(root)" },
|
||||
);
|
||||
}, [toggleTheme]);
|
||||
|
||||
const ariaLabel = theme === "dark" ? t`Switch to light theme` : t`Switch to dark theme`;
|
||||
|
||||
return (
|
||||
<Button size="icon" variant="ghost" ref={buttonRef} onClick={onToggleTheme} aria-label={ariaLabel} {...props}>
|
||||
{theme === "dark" ? <MoonIcon aria-hidden="true" /> : <SunIcon aria-hidden="true" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useMemo } from "react";
|
||||
import { cn } from "@/utils/style";
|
||||
import { Combobox, type ComboboxProps } from "../ui/combobox";
|
||||
import { MultipleCombobox, type MultipleComboboxProps } from "../ui/multiple-combobox";
|
||||
import { FontDisplay } from "./font-display";
|
||||
import type { LocalFont, WebFont } from "./types";
|
||||
import webFontListJSON from "./webfontlist.json";
|
||||
|
||||
type Weight = "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900";
|
||||
|
||||
const localFontList = [
|
||||
{ type: "local", category: "sans-serif", family: "Arial", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "sans-serif", family: "Calibri", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "sans-serif", family: "Helvetica", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "sans-serif", family: "Tahoma", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "sans-serif", family: "Trebuchet MS", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "sans-serif", family: "Verdana", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "serif", family: "Bookman", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "serif", family: "Cambria", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "serif", family: "Garamond", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "serif", family: "Georgia", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "serif", family: "Palatino", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "serif", family: "Times New Roman", weights: ["400", "600", "700"] },
|
||||
] as LocalFont[];
|
||||
const webFontList = webFontListJSON as WebFont[];
|
||||
|
||||
function buildWebFontMap() {
|
||||
const webFontMap = new Map<string, WebFont>();
|
||||
|
||||
for (const font of webFontList) {
|
||||
webFontMap.set(font.family, font);
|
||||
}
|
||||
|
||||
return webFontMap;
|
||||
}
|
||||
|
||||
const webFontMap: Map<string, WebFont> = buildWebFontMap();
|
||||
|
||||
export function getNextWeight(fontFamily: string): Weight | null {
|
||||
const fontData = webFontMap.get(fontFamily);
|
||||
if (!fontData || !Array.isArray(fontData.weights) || fontData.weights.length === 0) return null;
|
||||
if (fontData.weights.includes("400")) return "400";
|
||||
return fontData.weights[0] as Weight;
|
||||
}
|
||||
|
||||
type FontFamilyComboboxProps = Omit<ComboboxProps, "options">;
|
||||
|
||||
export function FontFamilyCombobox({ className, ...props }: FontFamilyComboboxProps) {
|
||||
const options = useMemo(() => {
|
||||
return [...localFontList, ...webFontList].map((font: LocalFont | WebFont) => ({
|
||||
value: font.family,
|
||||
keywords: [font.family],
|
||||
label: <FontDisplay name={font.family} type={font.type} url={"preview" in font ? font.preview : undefined} />,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return <Combobox options={options} className={cn("w-full", className)} {...props} />;
|
||||
}
|
||||
|
||||
type FontWeightComboboxProps = Omit<MultipleComboboxProps, "options"> & { fontFamily: string };
|
||||
|
||||
export function FontWeightCombobox({ fontFamily, ...props }: FontWeightComboboxProps) {
|
||||
const options = useMemo(() => {
|
||||
const fontData = webFontMap.get(fontFamily);
|
||||
if (!fontData || !Array.isArray(fontData.weights)) return [];
|
||||
|
||||
return fontData.weights.map((variant: string) => ({
|
||||
value: variant,
|
||||
label: variant,
|
||||
keywords: [variant],
|
||||
}));
|
||||
}, [fontFamily]);
|
||||
|
||||
return <MultipleCombobox options={options} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useInView } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
interface FontDisplayProps {
|
||||
name: string;
|
||||
url?: string;
|
||||
type: "local" | "web";
|
||||
}
|
||||
|
||||
const loadedFonts = new Set<string>();
|
||||
|
||||
export function FontDisplay({ name, url, type = "web" }: FontDisplayProps) {
|
||||
const previewName = type === "local" ? name : `${name} Preview`;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isLoaded, setIsLoaded] = useState(() => type === "local" || loadedFonts.has(previewName));
|
||||
const isInView = useInView(containerRef, { once: true, amount: 0.1, margin: "50px" });
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInView || isLoaded || !url) return;
|
||||
|
||||
const fontFace = new FontFace(previewName, `url(${url})`, { display: "swap" });
|
||||
|
||||
fontFace.load().then((loadedFace) => {
|
||||
if (!document.fonts.has(loadedFace)) document.fonts.add(loadedFace);
|
||||
|
||||
loadedFonts.add(previewName);
|
||||
setIsLoaded(true);
|
||||
});
|
||||
}, [isInView, isLoaded, previewName, url]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="inline">
|
||||
<span
|
||||
style={{ fontFamily: isLoaded ? `'${previewName}', sans-serif` : "sans-serif" }}
|
||||
className={cn(isLoaded ? "opacity-100" : "opacity-50", "transition-opacity duration-200 ease-in")}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
type Category = "display" | "handwriting" | "monospace" | "serif" | "sans-serif";
|
||||
type Weight = "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900";
|
||||
type FileWeight = Weight | `${Weight}italic`;
|
||||
|
||||
export type LocalFont = {
|
||||
type: "local";
|
||||
category: Category;
|
||||
family: string;
|
||||
weights: Weight[];
|
||||
};
|
||||
|
||||
export type WebFont = {
|
||||
type: "web";
|
||||
category: Category;
|
||||
family: string;
|
||||
weights: Weight[];
|
||||
preview: string;
|
||||
files: Record<FileWeight, string>;
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,55 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type * as React from "react";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
const alertVariants = cva(
|
||||
"group/alert relative grid w-full gap-0.5 rounded-lg border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 has-data-[slot=alert-action]:pr-18 *:[svg:not([class*='size-'])]:size-4 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return <div data-slot="alert" role="alert" className={cn(alertVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground text-sm md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="alert-action" className={cn("absolute top-2.5 right-3", className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction };
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Avatar as AvatarPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||
size?: "default" | "sm" | "lg";
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 select-none rounded-full after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full rounded-full object-cover", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-muted-foreground text-sm group-data-[size=sm]/avatar:text-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex select-none items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground text-sm ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarBadge };
|
||||
@@ -0,0 +1,39 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-4xl border border-transparent px-2 py-0.5 font-medium text-xs transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span";
|
||||
|
||||
return (
|
||||
<Comp data-slot="badge" data-variant={variant} className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,24 @@
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type Props = React.ComponentProps<"img"> & {
|
||||
variant?: "logo" | "icon";
|
||||
};
|
||||
|
||||
export function BrandIcon({ variant = "logo", className, ...props }: Props) {
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={`/${variant}/dark.svg`}
|
||||
alt="Reactive Resume"
|
||||
className={cn("hidden size-12 dark:block", className)}
|
||||
{...props}
|
||||
/>
|
||||
<img
|
||||
src={`/${variant}/light.svg`}
|
||||
alt="Reactive Resume"
|
||||
className={cn("block size-12 dark:hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-md!",
|
||||
vertical:
|
||||
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border bg-muted px-2.5 font-medium text-sm shadow-xs [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"relative self-stretch bg-input data-[orientation=horizontal]:mx-px data-[orientation=vertical]:my-px data-[orientation=vertical]:h-auto data-[orientation=horizontal]:w-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants };
|
||||
@@ -0,0 +1,163 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { CaretUpDownIcon, CheckIcon } from "@phosphor-icons/react";
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type ComboboxOption<TValue extends string | number = string> = {
|
||||
value: TValue;
|
||||
label: React.ReactNode;
|
||||
keywords?: string[];
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type ComboboxProps<TValue extends string | number = string> = Omit<
|
||||
React.ComponentProps<typeof PopoverContent>,
|
||||
"value" | "defaultValue" | "children"
|
||||
> & {
|
||||
disabled?: boolean;
|
||||
clearable?: boolean;
|
||||
options: ReadonlyArray<ComboboxOption<TValue>>;
|
||||
value?: TValue | null;
|
||||
defaultValue?: TValue | null;
|
||||
placeholder?: React.ReactNode;
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: React.ReactNode;
|
||||
buttonProps?: Omit<React.ComponentProps<typeof Button>, "children"> & {
|
||||
children?: (value: TValue | null, option: ComboboxOption<TValue> | null) => React.ReactNode;
|
||||
};
|
||||
onValueChange?: (value: TValue | null, option: ComboboxOption<TValue> | null) => void;
|
||||
};
|
||||
|
||||
function Combobox<TValue extends string | number = string>({
|
||||
disabled = false,
|
||||
clearable = true,
|
||||
options,
|
||||
value,
|
||||
defaultValue = null,
|
||||
placeholder = t`Select...`,
|
||||
searchPlaceholder = t`Search...`,
|
||||
emptyMessage = t`No results found.`,
|
||||
className,
|
||||
buttonProps,
|
||||
onValueChange,
|
||||
...props
|
||||
}: ComboboxProps<TValue>) {
|
||||
const { className: buttonClassName, children: buttonChildren, ...buttonRestProps } = buttonProps ?? {};
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const [selectedValue, setSelectedValue] = useControlledState<TValue | null>({
|
||||
value,
|
||||
defaultValue,
|
||||
onChange: (next) => onValueChange?.(next, options.find((o) => o.value === next) ?? null),
|
||||
});
|
||||
|
||||
const selectedOption = React.useMemo(() => {
|
||||
return options.find((option) => option.value === selectedValue) ?? null;
|
||||
}, [options, selectedValue]);
|
||||
|
||||
const selectedLabel = selectedOption?.label;
|
||||
|
||||
const onSelect = React.useCallback(
|
||||
(current: string) => {
|
||||
const next = (current as unknown as TValue) ?? null;
|
||||
|
||||
if (!clearable && selectedValue === next) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const toggled = selectedValue === next ? null : next;
|
||||
setSelectedValue(toggled);
|
||||
setOpen(false);
|
||||
},
|
||||
[clearable, selectedValue, setSelectedValue],
|
||||
);
|
||||
|
||||
// Prevent opening popover and handling interaction if disabled
|
||||
const handleOpenChange = React.useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (disabled) return;
|
||||
setOpen(nextOpen);
|
||||
},
|
||||
[disabled],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
role="combobox"
|
||||
variant="outline"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
aria-disabled={disabled}
|
||||
tapScale={1}
|
||||
className={cn(
|
||||
"font-normal active:scale-100",
|
||||
typeof buttonChildren === "function" ? "" : "justify-between",
|
||||
disabled && "pointer-events-none opacity-60",
|
||||
buttonClassName,
|
||||
)}
|
||||
{...buttonRestProps}
|
||||
>
|
||||
{typeof buttonChildren === "function" ? (
|
||||
buttonChildren(selectedValue, selectedOption)
|
||||
) : (
|
||||
<>
|
||||
{selectedLabel ?? placeholder}
|
||||
<CaretUpDownIcon className="ml-2 shrink-0 opacity-50" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className={cn("min-w-[200px] p-0", className, disabled && "pointer-events-none select-none opacity-60")}
|
||||
{...props}
|
||||
// Hide popover content visually and functionally if disabled (prevent tab focus etc)
|
||||
tabIndex={disabled ? -1 : undefined}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={searchPlaceholder} disabled={disabled} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedValue === option.value;
|
||||
const isDisabled = option.disabled ?? (false || disabled);
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={String(option.value)}
|
||||
value={String(option.value)}
|
||||
keywords={option.keywords}
|
||||
disabled={isDisabled}
|
||||
onSelect={isDisabled ? undefined : onSelect}
|
||||
aria-disabled={isDisabled}
|
||||
className={cn(isDisabled && "pointer-events-none opacity-60")}
|
||||
>
|
||||
<CheckIcon
|
||||
aria-hidden
|
||||
className={cn("size-4 shrink-0 transition-opacity", isSelected ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<span className={cn("truncate", isDisabled && "opacity-60")}>{option.label}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export type { ComboboxOption, ComboboxProps };
|
||||
|
||||
export { Combobox };
|
||||
@@ -0,0 +1,148 @@
|
||||
import { CheckIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import type * as React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/animate-ui/components/radix/dialog";
|
||||
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"flex size-full flex-col gap-y-1 overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className={cn("overflow-hidden rounded-xl! p-0", className)}>{children}</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
||||
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn("w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50", className)}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<MagnifyingGlassIcon className="size-4 shrink-0 opacity-50" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn("no-scrollbar max-h-72 scroll-py-1 overflow-y-auto overflow-x-hidden outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandEmpty({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className={cn("py-6 text-center text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground **:[[cmdk-group-heading]]:text-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("-mx-1 h-px w-auto bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandItem({ className, children, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"group/command-item relative flex cursor-default select-none items-center gap-2 in-data-[slot=dialog-content]:rounded-lg! rounded-sm px-2 py-1.5 text-sm outline-hidden data-[disabled=true]:pointer-events-none data-selected:bg-muted data-selected:text-foreground data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0 data-selected:**:[svg]:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||
</CommandPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-muted-foreground text-xs tracking-widest group-data-selected/command-item:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
@@ -0,0 +1,222 @@
|
||||
import { CaretRightIcon, CheckIcon } from "@phosphor-icons/react";
|
||||
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger
|
||||
data-slot="context-menu-trigger"
|
||||
className={cn("select-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-closed:animate-out data-open:animate-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/context-menu-item relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-inset:pl-8 data-[variant=destructive]:text-destructive data-disabled:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-inset:pl-8 data-open:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<CaretRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn(
|
||||
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg duration-100 data-closed:animate-out data-open:animate-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn("px-2 py-1.5 font-medium text-muted-foreground text-xs data-inset:pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-muted-foreground text-xs tracking-widest group-focus/context-menu-item:text-accent-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type Props = React.ComponentProps<"div">;
|
||||
|
||||
export function Copyright({ className, ...props }: Props) {
|
||||
return (
|
||||
<div className={cn("text-muted-foreground/80 text-xs leading-relaxed", className)} {...props}>
|
||||
<p>
|
||||
<Trans>
|
||||
Licensed under{" "}
|
||||
<a href="#" target="_blank" rel="noopener noreferrer" className="font-medium underline underline-offset-2">
|
||||
MIT
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Trans>By the community, for the community.</Trans>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Trans>
|
||||
A passion project by{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://amruthpillai.com"
|
||||
className="font-medium underline underline-offset-2"
|
||||
>
|
||||
Amruth Pillai
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-4">Reactive Resume v{__APP_VERSION__}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { isPlainObject } from "es-toolkit";
|
||||
import type { Label as LabelPrimitive } from "radix-ui";
|
||||
import { Slot as SlotPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Controller,
|
||||
type ControllerProps,
|
||||
type FieldError,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
} from "react-hook-form";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = { name: TName };
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState } = useFormContext();
|
||||
const formState = useFormState({ name: fieldContext.name });
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>");
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div data-slot="form-item" className={cn("grid gap-1.5", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function FormLabel({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("mb-0.5 data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof SlotPrimitive.Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||
|
||||
return (
|
||||
<SlotPrimitive.Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-xs leading-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField();
|
||||
|
||||
function extractMessage(obj: FieldError | undefined): string | undefined {
|
||||
if (!obj || typeof obj !== "object") return undefined;
|
||||
|
||||
if (isPlainObject(obj) && "message" in obj && typeof obj.message === "string") {
|
||||
return obj.message;
|
||||
}
|
||||
|
||||
for (const value of Object.values(obj)) {
|
||||
const found = extractMessage(value as FieldError);
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const body = extractMessage(error);
|
||||
|
||||
if (!body) return null;
|
||||
|
||||
return (
|
||||
<p
|
||||
id={formMessageId}
|
||||
data-error={!!error}
|
||||
data-slot="form-message"
|
||||
className={cn("line-clamp-1 text-xs", error ? "text-destructive" : "text-muted-foreground", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField };
|
||||
@@ -0,0 +1,35 @@
|
||||
import { HoverCard as HoverCardPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
function HoverCard({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
|
||||
}
|
||||
|
||||
function HoverCardTrigger({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return <HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-lg bg-popover p-4 text-popover-foreground text-sm shadow-md outline-hidden ring-1 ring-foreground/10 duration-100 data-closed:animate-out data-open:animate-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||
@@ -0,0 +1,133 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type * as React from "react";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group relative flex h-9 w-full min-w-0 items-center rounded-md border border-input shadow-xs outline-none transition-[color,box-shadow] in-data-[slot=combobox-content]:focus-within:border-ring has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-start]]:h-auto has-[>textarea]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:flex-col has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 font-medium text-muted-foreground text-sm group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start": "order-first pl-2 has-[>button]:-ml-1 has-[>kbd]:ml-[-0.15rem]",
|
||||
"inline-end": "order-last pr-2 has-[>button]:-mr-1 has-[>kbd]:mr-[-0.15rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
|
||||
"block-end": "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return;
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus();
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva("flex items-center gap-2 text-sm shadow-none", {
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "",
|
||||
"icon-xs": "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
});
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> & VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
type={type}
|
||||
asChild={false}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-muted-foreground text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupInput({ className, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none aria-invalid:ring-0 dark:bg-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none aria-invalid:ring-0 dark:bg-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea };
|
||||
@@ -0,0 +1,80 @@
|
||||
import { MinusIcon } from "@phosphor-icons/react";
|
||||
import { OTPInput, OTPInputContext } from "input-otp";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof OTPInput> & {
|
||||
containerClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn("cn-input-otp flex items-center has-disabled:opacity-50", containerClassName)}
|
||||
spellCheck={false}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-group"
|
||||
className={cn(
|
||||
"flex items-center rounded-md has-aria-invalid:border-destructive has-aria-invalid:ring-[3px] has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
index: number;
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"relative flex size-9 items-center justify-center border-input border-y border-r text-sm shadow-xs outline-none transition-all first:rounded-l-md first:border-l last:rounded-r-md aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-[3px] data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="size-4 animate-caret-blink bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-separator"
|
||||
className="mx-3 flex items-center [&_svg:not([class*='size-'])]:size-4"
|
||||
role="separator"
|
||||
{...props}
|
||||
>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
@@ -0,0 +1,18 @@
|
||||
import type * as React from "react";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 py-1 shadow-xs outline-none transition-[color,box-shadow] file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user