initial commit of v5

This commit is contained in:
Amruth Pillai
2026-01-19 23:31:54 +01:00
parent 55bdfd0067
commit cad390fa13
1132 changed files with 200807 additions and 165288 deletions
@@ -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,
};
+86
View File
@@ -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>
);
};
+129
View File
@@ -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}
/>
);
}
+111
View File
@@ -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>
);
};
+113
View File
@@ -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>
);
};
+125
View File
@@ -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>
</>
);
}
+59
View File
@@ -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: [] };
});
},
}));
+123
View File
@@ -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>
);
}
+831
View File
@@ -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>
);
}
+117
View File
@@ -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>
);
}
+125
View File
@@ -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;
}
}
+667
View File
@@ -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}
/>
);
}
+82
View File
@@ -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>
);
}
+27
View File
@@ -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>
);
}
+13
View File
@@ -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>
);
}
+33
View File
@@ -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} />;
}
+65
View File
@@ -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>
);
}
+26
View File
@@ -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]);
}
+69
View File
@@ -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);
}
}
+136
View File
@@ -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>
);
}
+62
View File
@@ -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));
});
},
})),
);
+123
View File
@@ -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>
);
}
+102
View File
@@ -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>
);
}
+111
View File
@@ -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>
);
}
+123
View File
@@ -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>
);
}
+113
View File
@@ -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>
);
}
+116
View File
@@ -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>
);
}
+110
View File
@@ -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>
);
}
+94
View File
@@ -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>
);
}
+111
View File
@@ -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>
);
}
+7
View File
@@ -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>;
};
+28
View File
@@ -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} />;
}
+49
View File
@@ -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;
}
+65
View File
@@ -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>
);
}
+75
View File
@@ -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>
);
}
+19
View File
@@ -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
+55
View File
@@ -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 };
+90
View File
@@ -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 };
+39
View File
@@ -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 };
+24
View File
@@ -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}
/>
</>
);
}
+77
View File
@@ -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 };
+163
View File
@@ -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 };
+148
View File
@@ -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,
};
+222
View File
@@ -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,
};
+41
View File
@@ -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>
);
}
+155
View File
@@ -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 };
+35
View File
@@ -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 };
+133
View File
@@ -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 };
+80
View File
@@ -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 };
+18
View File
@@ -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