Add Activepieces integration for workflow automation

- Add Activepieces fork with SmoothSchedule custom piece
- Create integrations app with Activepieces service layer
- Add embed token endpoint for iframe integration
- Create Automations page with embedded workflow builder
- Add sidebar visibility fix for embed mode
- Add list inactive customers endpoint to Public API
- Include SmoothSchedule triggers: event created/updated/cancelled
- Include SmoothSchedule actions: create/update/cancel events, list resources/services/customers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-18 22:59:37 -05:00
parent 9848268d34
commit 3aa7199503
16292 changed files with 1284892 additions and 4708 deletions

View File

@@ -0,0 +1,66 @@
'use client';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDown } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Accordion = ({
className,
...props
}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Root>) => {
return (
<AccordionPrimitive.Root
className={cn('rounded-md border', className)}
{...props}
/>
);
};
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn(className)} {...props} />
));
AccordionItem.displayName = 'AccordionItem';
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 px-4 py-3 items-center justify-between text-sm font-medium transition-all text-left [&[data-state=open]>svg]:rotate-180',
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className={cn(
'overflow-hidden px-3 pb-3 text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down',
className,
)}
{...props}
>
{children}
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -0,0 +1,63 @@
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils';
const alertVariants = cva(
'flex relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
{
variants: {
variant: {
default: 'bg-background text-foreground',
warning:
'border-warning/50 text-warning-300 dark:border-warning [&>svg]:text-warning',
destructive:
'border-destructive/50 text-destructive-300 dark:border-destructive [&>svg]:text-destructive',
primary:
'border-primary/50 text-primary bg-primary-100/10 dark:border-primary [&>svg]:text-primary',
},
},
defaultVariants: {
variant: 'default',
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
/>
));
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-sm [&_p]:leading-relaxed', className)}
{...props}
/>
));
AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1,107 @@
import { t } from 'i18next';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from './command';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
import { ScrollArea } from './scroll-area';
type Props<T extends string> = {
selectedValue: T;
onSelectedValueChange: (value: T) => void;
items: { value: T; label: string }[];
children: React.ReactNode;
className?: string;
open: boolean;
setOpen: (open: boolean) => void;
listRef?: React.RefObject<HTMLDivElement>;
};
export function AutoComplete<T extends string>({
selectedValue,
onSelectedValueChange,
items,
children,
className,
open,
setOpen,
listRef,
}: Props<T>) {
const onSelectItem = (inputValue: string) => {
onSelectedValueChange(inputValue as T);
setOpen(false);
};
return (
<div className="flex items-center">
<Popover
open={open}
onOpenChange={(open) => {
setOpen(open);
}}
>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
asChild
onOpenAutoFocus={(e) => e.preventDefault()}
onInteractOutside={(e) => {
if (
e.target instanceof Element &&
e.target.hasAttribute('cmdk-input')
) {
e.preventDefault();
}
}}
className="w-(--radix-popover-trigger-width) p-0"
>
<Command className={className} ref={listRef}>
<CommandList className="bg-background">
<ScrollArea
className={cn('', {
'h-50': items.length >= 5,
'h-10': items.length === 1,
'h-20': items.length === 2,
'h-30': items.length === 3,
'h-40': items.length === 4,
})}
>
{items.length > 0 ? (
<CommandGroup>
{items.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onMouseDown={(e) => e.preventDefault()}
onSelect={onSelectItem}
>
<Check
className={cn(
'h-4 w-4',
selectedValue === option.value
? 'opacity-100'
: 'opacity-0',
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
) : (
<CommandEmpty>{t('No items')}</CommandEmpty>
)}
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full border-none',
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,35 @@
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground',
accent: 'border-transparent bg-accent text-accent-foreground',
destructive: 'border-transparent bg-destructive-100',
outline: 'text-foreground',
ghost: 'border-none',
success: 'bg-success-100',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,115 @@
import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<'nav'> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = 'Breadcrumb';
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<'ol'>
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
'flex flex-wrap items-center gap-1.5 wrap-break-word text-sm text-muted-foreground sm:gap-2.5',
className,
)}
{...props}
/>
));
BreadcrumbList.displayName = 'BreadcrumbList';
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<'li'>
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
));
BreadcrumbItem.displayName = 'BreadcrumbItem';
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<'a'> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : 'a';
return (
<Comp
ref={ref}
className={cn('transition-colors hover:text-foreground', className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = 'BreadcrumbLink';
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<'span'>
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn('font-normal text-foreground', className)}
{...props}
/>
));
BreadcrumbPage.displayName = 'BreadcrumbPage';
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<'li'>) => (
<li
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:w-3.5 [&>svg]:h-3.5', className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
role="presentation"
aria-hidden="true"
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -0,0 +1,144 @@
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Shortcut } from './shortcut';
import { LoadingSpinner } from './spinner';
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-hidden focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
'bg-primary stroke-background text-primary-foreground enabled:hover:bg-primary/90',
basic:
'text-primary font-medium underline-offset-4 enabled:hover:bg-accent',
secondary:
'text-secondary-foreground bg-secondary enabled:hover:bg-secondary/80 enabled:hover:text-secondary-foreground',
destructive:
'bg-destructive text-primary-foreground enabled:hover:bg-destructive/90',
outline:
'border-input bg-background enabled:hover:bg-accent enabled:hover:text-accent-foreground border',
accent: 'bg-accent text-accent-foreground enabled:hover:bg-accent/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
transparent: 'text-primary enabled:hover:bg-transparent',
'outline-primary':
'text-primary font-medium enabled:hover:bg-primary/10 enabled:hover:border-primary enabled:hover:font-semibold',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
xs: 'h-6 px-2 text-xs py-2',
},
},
compoundVariants: [
{
variant: 'link',
class: 'px-0',
},
],
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
loading?: boolean;
keyboardShortcut?: string;
onKeyboardShortcut?: () => void;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant,
size,
asChild = false,
loading = false,
keyboardShortcut,
disabled,
onKeyboardShortcut,
children,
...props
},
ref,
) => {
const Comp = asChild ? Slot : 'button';
const isMac = /(Mac)/i.test(navigator.userAgent);
const isEscape = keyboardShortcut?.toLocaleLowerCase() === 'esc';
React.useEffect(() => {
if (keyboardShortcut) {
document.addEventListener('keydown', handleKeyDown);
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [keyboardShortcut, disabled]);
const handleKeyDown = (event: KeyboardEvent) => {
const isEscapePressed = event.key === 'Escape' && isEscape;
const isCtrlWithShortcut =
keyboardShortcut &&
event.key === keyboardShortcut.toLocaleLowerCase() &&
(isMac ? event.metaKey : event.ctrlKey);
if (isEscapePressed || isCtrlWithShortcut) {
event.preventDefault();
event.stopPropagation();
if (onKeyboardShortcut && !disabled) {
onKeyboardShortcut();
}
}
};
return (
<Comp
className={cn(buttonVariants({ variant, size, className }), {})}
ref={ref}
disabled={disabled || loading}
{...props}
onClick={(e) => {
loading ? e.stopPropagation() : props.onClick && props.onClick(e);
}}
>
{loading ? (
<LoadingSpinner
className={cn('size-5', {
'stroke-background':
variant === 'default' || variant === 'secondary',
'stroke-foreground':
variant !== 'default' && variant !== 'secondary',
})}
/>
) : (
<>
{keyboardShortcut && (
<div className="flex justify-center items-center gap-2">
{children}
<Shortcut shortcutKey={keyboardShortcut} withCtrl={true} />
</div>
)}
{!keyboardShortcut && children}
</>
)}
</Comp>
);
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@@ -0,0 +1,68 @@
'use client';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import * as React from 'react';
import { DayPicker } from 'react-day-picker';
import { buttonVariants } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
// TODO: upgrade to react-day-picker v9 when https://github.com/shadcn-ui/ui/pull/4421 is merged
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
classNames={{
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: 'space-y-4',
caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center',
nav_button: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
),
nav_button_previous: 'absolute left-1',
nav_button_next: 'absolute right-1',
table: 'w-full border-collapse space-y-1',
head_row: 'flex',
head_cell:
'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
row: 'flex w-full mt-2',
cell: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal aria-selected:opacity-100',
),
day_range_end: 'day-range-end',
day_selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground',
day_outside:
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
day_disabled: 'text-muted-foreground opacity-50',
day_range_middle:
'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: 'invisible',
...classNames,
}}
components={{
IconLeft: () => <ChevronLeft className="h-4 w-4" />,
IconRight: () => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = 'Calendar';
export { Calendar };

View File

@@ -0,0 +1,97 @@
import { cva, VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils';
const cardVariants = cva('rounded-lg border bg-background text-foreground', {
variants: {
variant: {
default: ' shadow-xs',
interactive:
'cursor-pointer hover:border-gray-400 transition-colors duration-200 flex flex-col justify-between',
},
},
defaultVariants: {
variant: 'default',
},
});
interface CardProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof cardVariants> {}
const Card = React.forwardRef<HTMLDivElement, CardProps>(
({ className, variant, ...props }, ref) => (
<div
ref={ref}
className={cn(cardVariants({ variant }), className)}
{...props}
/>
),
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
));
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
));
CardFooter.displayName = 'CardFooter';
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,262 @@
'use client';
import { ArrowLeftIcon, ArrowRightIcon } from '@radix-ui/react-icons';
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from 'embla-carousel-react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: 'horizontal' | 'vertical';
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error('useCarousel must be used within a <Carousel />');
}
return context;
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = 'horizontal',
opts,
setApi,
plugins,
className,
children,
...props
},
ref,
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === 'horizontal' ? 'x' : 'y',
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollPrev();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on('reInit', onSelect);
api.on('select', onSelect);
return () => {
api?.off('select', onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn('relative', className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
},
);
Carousel.displayName = 'Carousel';
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
'flex',
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
className,
)}
{...props}
/>
</div>
);
});
CarouselContent.displayName = 'CarouselContent';
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
className,
)}
{...props}
/>
);
});
CarouselItem.displayName = 'CarouselItem';
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
'absolute h-8 w-8 rounded-full',
orientation === 'horizontal'
? '-left-12 top-1/2 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeftIcon className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
});
CarouselPrevious.displayName = 'CarouselPrevious';
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
'absolute h-8 w-8 rounded-full',
orientation === 'horizontal'
? '-right-12 top-1/2 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRightIcon className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
});
CarouselNext.displayName = 'CarouselNext';
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};

View File

@@ -0,0 +1,365 @@
'use client';
import * as React from 'react';
import * as RechartsPrimitive from 'recharts';
import { cn } from '@/lib/utils';
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error('useChart must be used within a <ChartContainer />');
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>['children'];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-hidden [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = 'Chart';
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([_, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join('\n')}
}
`,
)
.join('\n'),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<'div'> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: 'line' | 'dot' | 'dashed';
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref,
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item.dataKey || item.name || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === 'string'
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn('font-medium', labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== 'dot';
return (
<div
ref={ref}
className={cn(
'grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl',
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
indicator === 'dot' && 'items-center',
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
'shrink-0 rounded-[2px] border-border bg-(--color-bg)',
{
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent':
indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
},
)}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
'flex flex-1 justify-between leading-none',
nestLabel ? 'items-end' : 'items-center',
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = 'ChartTooltip';
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> &
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
hideIcon?: boolean;
nameKey?: string;
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey },
ref,
) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
'flex items-center justify-center gap-4',
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground',
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
},
);
ChartLegendContent.displayName = 'ChartLegend';
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== 'object' || payload === null) {
return undefined;
}
const payloadPayload =
'payload' in payload &&
typeof payload.payload === 'object' &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === 'string'
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@@ -0,0 +1,53 @@
'use client';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { cva, type VariantProps } from 'class-variance-authority';
import { CheckIcon, MinusIcon } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';
const checkboxVariants = cva(
'peer border-input dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
variant: {
primary:
'data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground data-[state=indeterminate]:border-primary',
secondary:
'data-[state=checked]:bg-secondary data-[state=checked]:text-secondary-foreground dark:data-[state=checked]:bg-secondary data-[state=checked]:border-secondary data-[state=indeterminate]:bg-secondary data-[state=indeterminate]:text-secondary-foreground data-[state=indeterminate]:border-secondary',
},
},
defaultVariants: {
variant: 'primary',
},
},
);
interface CheckboxProps
extends React.ComponentProps<typeof CheckboxPrimitive.Root>,
VariantProps<typeof checkboxVariants> {}
function Checkbox({ className, variant, checked, ...props }: CheckboxProps) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
checked={checked}
className={cn(checkboxVariants({ variant }), className)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
{checked === 'indeterminate' ? (
<MinusIcon className="size-3.5" />
) : (
<CheckIcon className="size-3.5" />
)}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox, checkboxVariants };
export type { CheckboxProps };

View File

@@ -0,0 +1,11 @@
'use client';
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,73 @@
'use client';
import { forwardRef, useMemo, useState } from 'react';
import { HexColorPicker } from 'react-colorful';
import type { ButtonProps } from '@/components/ui/button';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { useForwardedRef, cn } from '@/lib/utils';
interface ColorPickerProps {
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
}
const ColorPicker = forwardRef<
HTMLInputElement,
Omit<ButtonProps, 'value' | 'onChange' | 'onBlur'> & ColorPickerProps
>(
(
{ disabled, value, onChange, onBlur, name, className, ...props },
forwardedRef,
) => {
const ref = useForwardedRef(forwardedRef);
const [open, setOpen] = useState(false);
const parsedValue = useMemo(() => {
return value || '#FFFFFF';
}, [value]);
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild disabled={disabled} onBlur={onBlur}>
<Button
{...props}
className={cn('block rounded-full', className)}
name={name}
onClick={() => {
setOpen(true);
}}
size="icon"
style={{
backgroundColor: parsedValue,
}}
variant="outline"
>
<div />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full">
<HexColorPicker color={parsedValue} onChange={onChange} />
<Input
maxLength={7}
onChange={(e) => {
onChange(e?.currentTarget?.value);
}}
ref={ref}
value={parsedValue}
/>
</PopoverContent>
</Popover>
);
},
);
ColorPicker.displayName = 'ColorPicker';
export { ColorPicker };

View File

@@ -0,0 +1,160 @@
import { type DialogProps } from '@radix-ui/react-dialog';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import * as React from 'react';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-background text-foreground',
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
type CommandDialogProps = DialogProps;
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="**:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 **:[[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:size-5 **:[[cmdk-input]]:h-12 **:[[cmdk-item]]:px-2 **:[[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:size-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
// eslint-disable-next-line react/no-unknown-property -- temp fix while using wrong cmdk package
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground',
{ 'cursor-not-allowed opacity-50': props.disabled },
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn(
'max-h-[300px] overflow-y-hidden overflow-x-hidden',
className,
)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground',
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, disabled, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden aria-selected:bg-accent aria-selected:text-accent-foreground',
{ 'pointer-events-none opacity-50': disabled },
className,
)}
disabled={disabled}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className,
)}
{...props}
/>
);
};
CommandShortcut.displayName = 'CommandShortcut';
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,200 @@
'use client';
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
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-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-32 overflow-hidden rounded-md border bg-background p-1 text-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=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',
className,
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
'z-50 min-w-32 overflow-hidden rounded-md border bg-background p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=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',
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
'relative 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-disabled:pointer-events-none data-disabled:opacity-50',
inset && 'pl-8',
className,
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold text-foreground',
inset && 'pl-8',
className,
)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className,
)}
{...props}
/>
);
};
ContextMenuShortcut.displayName = 'ContextMenuShortcut';
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View File

@@ -0,0 +1,26 @@
import React from 'react';
interface DataTableBulkActionsProps<TData> {
selectedRows: TData[];
actions: Array<{
render: (
selectedRows: TData[],
resetSelection: () => void,
) => React.ReactNode;
}>;
}
export function DataTableBulkActions<TData>({
selectedRows,
actions,
}: DataTableBulkActionsProps<TData>) {
return (
<div className="flex items-center justify-center space-x-2 ">
{actions.map((action, index) => (
<React.Fragment key={index}>
{action.render(selectedRows, () => {})}
</React.Fragment>
))}
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
type DataTableCheckboxProps = {
label: string;
checked: boolean;
handleCheckedChange: (checked: boolean) => void;
};
export function DataTableInputCheckbox({
label,
checked,
handleCheckedChange,
}: DataTableCheckboxProps) {
return (
<Button
type="button"
variant="outline"
className={cn(
'flex items-center space-x-2 border-dashed rounded-md px-3 py-2 h-9',
'hover:bg-accent/5',
checked && 'bg-accent/10 border-accent text-accent-foreground',
)}
onClick={() => handleCheckedChange(!checked)}
>
<Checkbox checked={checked} className="pointer-events-none" />
<Label className="text-sm font-medium leading-none select-none cursor-pointer">
{label}
</Label>
</Button>
);
}

View File

@@ -0,0 +1,22 @@
import { Column } from '@tanstack/react-table';
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>;
title: string;
}
export function DataTableColumnHeader<TData, TValue>({
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
return (
<div
className={`flex items-center justify-start space-x-2 py-4 ${className}`}
>
<div className="text-sm font-semibold text-black dark:text-white">
{title}
</div>
</div>
);
}

View File

@@ -0,0 +1,176 @@
import { Column } from '@tanstack/react-table';
import * as React from 'react';
import { DateRange } from 'react-day-picker';
import { useSearchParams } from 'react-router-dom';
import { DateTimePickerWithRange, PresetKey } from '../date-time-picker-range';
import { DataTableInputCheckbox } from './data-table-checkbox-filter';
import { DataTableInputPopover } from './data-table-input-popover';
import { DataTableSelectPopover } from './data-table-select-popover';
import { CURSOR_QUERY_PARAM } from '.';
type DropdownFilterProps = {
type: 'select';
options: {
label: string;
value: string;
icon?: React.ComponentType<{ className?: string }> | string;
}[];
};
type InputFilterProps = {
type: 'input';
};
type DateFilterProps = {
type: 'date';
defaultPresetName?: PresetKey;
};
type CheckboxjhFilterProps = {
type: 'checkbox';
};
export type DataTableFilterProps = {
title?: string;
icon?: React.ComponentType<{ className?: string }>;
} & (
| DropdownFilterProps
| InputFilterProps
| DateFilterProps
| CheckboxjhFilterProps
);
export function DataTableFilter<TData, TValue>({
title,
column,
...props
}: DataTableFilterProps & { column?: Column<TData, TValue> }) {
const facets = column?.getFacetedUniqueValues();
const [searchParams, setSearchParams] = useSearchParams();
const handleFilterChange = React.useCallback(
(filterValue: string | string[] | DateRange | undefined) => {
setSearchParams(
(prev) => {
const newParams = new URLSearchParams(prev);
newParams.delete(column?.id as string);
newParams.delete(`${column?.id}After`);
newParams.delete(`${column?.id}Before`);
newParams.delete(CURSOR_QUERY_PARAM);
if (!filterValue) {
return newParams;
}
if (Array.isArray(filterValue)) {
filterValue.forEach((value) =>
newParams.append(column?.id as string, value),
);
} else if (typeof filterValue === 'object' && filterValue !== null) {
if (filterValue.from) {
newParams.append(
`${column?.id}After`,
filterValue.from.toISOString(),
);
}
if (filterValue.to) {
newParams.append(
`${column?.id}Before`,
filterValue.to.toISOString(),
);
}
} else {
newParams.append(column?.id as string, filterValue);
}
return newParams;
},
{ replace: true },
);
if (Array.isArray(filterValue)) {
column?.setFilterValue(filterValue.length ? filterValue : undefined);
} else if (typeof filterValue === 'object' && filterValue !== null) {
column?.setFilterValue(
filterValue.from || filterValue.to ? filterValue : undefined,
);
} else {
column?.setFilterValue(filterValue ? filterValue : undefined);
}
},
[column, setSearchParams],
);
switch (props.type) {
case 'input': {
const filterValue = searchParams.get(column?.id as string) || '';
return (
<DataTableInputPopover
title={title}
filterValue={filterValue}
handleFilterChange={handleFilterChange}
/>
);
}
case 'select': {
const filterValue = searchParams.getAll(column?.id as string) as string[];
const selectedValues = new Set(filterValue);
return (
<DataTableSelectPopover
title={title}
selectedValues={selectedValues}
options={props.options}
handleFilterChange={handleFilterChange}
facets={facets}
/>
);
}
case 'date': {
const from = searchParams.get(`${column?.id}After`);
const to = searchParams.get(`${column?.id}Before`);
return (
<DateTimePickerWithRange
defaultSelectedRange={props.defaultPresetName}
presetType="past"
onChange={handleFilterChange}
from={from ?? undefined}
to={to ?? undefined}
/>
);
}
case 'checkbox': {
const key = column?.id || 'archivedAt';
const isArchived = searchParams.get(key) === 'true';
const handleCheckedChange = (checked: boolean) => {
setSearchParams(
(prev) => {
const newParams = new URLSearchParams(prev);
newParams.delete(key);
newParams.delete(CURSOR_QUERY_PARAM);
if (checked) {
newParams.append(key, 'true');
}
return newParams;
},
{ replace: true },
);
column?.setFilterValue(
checked
? (row: any) => row.getValue('archivedAt') !== null
: undefined,
);
};
return (
<DataTableInputCheckbox
label={title ?? 'Archived'}
checked={isArchived}
handleCheckedChange={handleCheckedChange}
/>
);
}
}
}

View File

@@ -0,0 +1,60 @@
import { SearchIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useDebounce } from 'use-debounce';
import { Badge } from '../badge';
import { Button } from '../button';
import { Input } from '../input';
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
import { Separator } from '../separator';
type DataTableInputPopoverProps = {
title?: string;
filterValue: string;
handleFilterChange: (filterValue: string) => void;
};
const DataTableInputPopover = ({
title,
filterValue,
handleFilterChange,
}: DataTableInputPopoverProps) => {
const [searchQuery, setSearchQuery] = useState(filterValue);
const [debouncedQuery] = useDebounce(searchQuery, 300);
useEffect(() => {
handleFilterChange(debouncedQuery);
}, [debouncedQuery]);
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="border-dashed">
<SearchIcon className="mr-2 size-4" />
{title}
{filterValue.length > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="accent"
className="rounded-sm px-1 font-normal max-w-40 truncate"
>
{filterValue}
</Badge>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Input
type="text"
placeholder="Name"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</PopoverContent>
</Popover>
);
};
export { DataTableInputPopover };

View File

@@ -0,0 +1,161 @@
import { CheckIcon, ListFilterIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '../badge';
import { Button } from '../button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '../command';
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
import { ScrollArea } from '../scroll-area';
import { Separator } from '../separator';
type DataTableSelectPopoverProps = {
title?: string;
selectedValues: Set<string>;
options: readonly {
label: string;
value: string;
icon?: React.ComponentType<{ className?: string }> | string;
}[];
facets?: Map<any, number>;
handleFilterChange: (filterValue: string[]) => void;
};
const DataTableSelectPopover = ({
title,
selectedValues,
options,
handleFilterChange,
facets,
}: DataTableSelectPopoverProps) => {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="border-dashed">
<ListFilterIcon className="mr-2 size-4" />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="accent"
className="rounded-sm px-1 font-normal lg:hidden"
>
{selectedValues.size}
</Badge>
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge
variant="accent"
className="rounded-sm px-1 font-normal"
>
{selectedValues.size} selected
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant="accent"
key={option.value}
className="rounded-sm px-1 font-normal"
>
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="min-w-[200px] max-w-[250px] break-all p-0"
align="start"
>
<Command>
<CommandInput placeholder={title} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
<ScrollArea viewPortClassName="max-h-[200px]">
{options.map((option, index) => {
const isSelected = selectedValues.has(option.value);
return (
<CommandItem
key={option.value}
onSelect={() => {
if (isSelected) {
selectedValues.delete(option.value);
} else {
selectedValues.add(option.value);
}
const filterValues = Array.from(selectedValues);
handleFilterChange(filterValues);
}}
>
<div
className={cn(
'mr-2 flex h-4 w-4 items-center justify-center rounded border border-secondary',
isSelected
? 'bg-secondary text-secondary-foreground'
: 'opacity-50 [&_svg]:invisible',
)}
>
<CheckIcon className={cn('h-4 w-4')} />
</div>
{typeof option.icon === 'string' ? (
<img
src={option.icon}
alt={option.label}
className="mr-2 size-4 object-contain"
/>
) : (
option.icon && (
<option.icon className="mr-2 size-4 text-muted-foreground" />
)
)}
<div>
<span>{option.label}</span>
<span className="hidden">{index}</span>
</div>
{facets?.get(option.value) && (
<span className="ml-auto flex size-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)}
</span>
)}
</CommandItem>
);
})}
</ScrollArea>
</CommandGroup>
{selectedValues.size > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => handleFilterChange([])}
className="justify-center text-center"
>
Clear filters
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
export { DataTableSelectPopover };

View File

@@ -0,0 +1,29 @@
import { Skeleton } from '@/components/ui/skeleton';
export function DataTableSkeleton({
skeletonRowCount = 10,
}: {
skeletonRowCount?: number;
}) {
return (
<div>
<div className="p-2">
{Array.from({ length: skeletonRowCount }).map((_, rowIndex) => (
<TableRowSkeleton key={rowIndex} />
))}
</div>
</div>
);
}
function TableRowSkeleton() {
return (
<div
id="table-loading"
className="w-full h-10 bg-gray-100 mb-4 dark:bg-gray-800 rounded-sm"
data-testid="header-cell"
>
<Skeleton className="w-full" />
</div>
);
}

View File

@@ -0,0 +1,16 @@
type DataTableToolbarProps = {
children?: React.ReactNode;
};
const DataTableToolbar = (params: DataTableToolbarProps) => {
return (
<div className="flex items-center justify-between pb-4 overflow-auto">
<div className="flex flex-1 items-center space-x-2">
{params.children}
</div>
</div>
);
};
DataTableToolbar.displayName = 'DataTableToolbar';
export { DataTableToolbar };

View File

@@ -0,0 +1,461 @@
'use client';
import {
ColumnDef as TanstackColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { t } from 'i18next';
import React, { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useDeepCompareEffect } from 'react-use';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { cn } from '@/lib/utils';
import { apId, isNil, SeekPage } from '@activepieces/shared';
import { Button } from '../button';
import { Checkbox } from '../checkbox';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '../select';
import { DataTableBulkActions } from './data-table-bulk-actions';
import { DataTableColumnHeader } from './data-table-column-header';
import { DataTableFilter, DataTableFilterProps } from './data-table-filter';
import { DataTableSkeleton } from './data-table-skeleton';
import { DataTableToolbar } from './data-table-toolbar';
export type DataWithId = {
id?: string;
};
export type RowDataWithActions<TData extends DataWithId> = TData & {
delete: () => void;
update: (payload: Partial<TData>) => void;
};
export const CURSOR_QUERY_PARAM = 'cursor';
export const LIMIT_QUERY_PARAM = 'limit';
type DataTableAction<TData extends DataWithId> = (
row: RowDataWithActions<TData>,
) => JSX.Element;
type ColumnDef<TData, TValue> = TanstackColumnDef<TData, TValue> & {
notClickable?: boolean;
};
interface DataTableProps<
TData extends DataWithId,
TValue,
Keys extends string,
> {
columns: ColumnDef<RowDataWithActions<TData>, TValue>[];
page: SeekPage<TData> | undefined;
onRowClick?: (
row: RowDataWithActions<TData>,
newWindow: boolean,
e: React.MouseEvent<HTMLTableRowElement, MouseEvent>,
) => void;
isLoading: boolean;
filters?: DataTableFilters<Keys>[];
customFilters?: React.ReactNode[];
onSelectedRowsChange?: (rows: RowDataWithActions<TData>[]) => void;
actions?: DataTableAction<TData>[];
hidePagination?: boolean;
bulkActions?: BulkAction<TData>[];
emptyStateTextTitle: string;
emptyStateTextDescription: string;
emptyStateIcon: React.ReactNode;
selectColumn?: boolean;
}
export type DataTableFilters<Keys extends string> = DataTableFilterProps & {
accessorKey: Keys;
};
export type BulkAction<TData extends DataWithId> = {
render: (
selectedRows: RowDataWithActions<TData>[],
resetSelection: () => void,
) => React.ReactNode;
};
export function DataTable<
TData extends DataWithId,
TValue,
Keys extends string,
>({
columns: columnsInitial,
page,
onRowClick,
filters = [],
actions = [],
isLoading,
onSelectedRowsChange,
hidePagination,
bulkActions = [],
emptyStateTextTitle,
emptyStateTextDescription,
emptyStateIcon,
customFilters,
selectColumn = false,
}: DataTableProps<TData, TValue, Keys>) {
const selectColumnDef: ColumnDef<RowDataWithActions<TData>, TValue> = {
id: 'select',
accessorKey: 'select',
notClickable: true,
header: ({ table }) => (
<div className="flex items-center h-full">
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center h-full">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
/>
</div>
),
};
const columnsWithSelect = selectColumn
? [selectColumnDef, ...columnsInitial]
: columnsInitial;
const columns =
actions.length > 0
? columnsWithSelect.concat([
{
accessorKey: '__actions',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="" />
),
cell: ({ row }) => {
return (
<div className="flex justify-end gap-4">
{actions.map((action, index) => {
return (
<React.Fragment key={index}>
{action(row.original)}
</React.Fragment>
);
})}
</div>
);
},
},
])
: columnsWithSelect;
const columnVisibility = columnsInitial.reduce((acc, column) => {
if (column.enableHiding && 'accessorKey' in column) {
acc[column.accessorKey as string] = false;
}
return acc;
}, {} as Record<string, boolean>);
const [searchParams, setSearchParams] = useSearchParams();
const startingCursor = searchParams.get('cursor') || undefined;
const startingLimit = searchParams.get('limit') || '10';
const [currentCursor, setCurrentCursor] = useState<string | undefined>(
startingCursor,
);
const [nextPageCursor, setNextPageCursor] = useState<string | undefined>(
page?.next ?? undefined,
);
const [previousPageCursor, setPreviousPageCursor] = useState<
string | undefined
>(page?.previous ?? undefined);
const enrichPageData = (data: TData[]) => {
return data.map((row, index) => ({
...row,
delete: () => {
setDeletedRows((prevDeletedRows) => [...prevDeletedRows, row]);
},
update: (payload: Partial<TData>) => {
setTableData((prevData) => {
const newData = [...prevData];
newData[index] = { ...newData[index], ...payload };
return newData;
});
},
}));
};
const [deletedRows, setDeletedRows] = useState<TData[]>([]);
const [tableData, setTableData] = useState<RowDataWithActions<TData>[]>(
enrichPageData(page?.data ?? []),
);
useDeepCompareEffect(() => {
setNextPageCursor(page?.next ?? undefined);
setPreviousPageCursor(page?.previous ?? undefined);
setTableData(enrichPageData(page?.data ?? []));
}, [page?.data]);
const table = useReactTable({
data: tableData,
columns,
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
getRowId: () => apId(),
initialState: {
pagination: {
pageSize: parseInt(startingLimit),
},
columnVisibility,
},
});
useEffect(() => {
filters?.forEach((filter) => {
const column = table.getColumn(filter.accessorKey);
const values = searchParams.getAll(filter.accessorKey);
if (column && values) {
column.setFilterValue(values);
}
});
}, []);
useDeepCompareEffect(() => {
onSelectedRowsChange?.(
table.getSelectedRowModel().rows.map((row) => row.original),
);
}, [table.getSelectedRowModel().rows]);
useEffect(() => {
setSearchParams(
(prev) => {
const newParams = new URLSearchParams(prev);
newParams.set('cursor', currentCursor ?? '');
newParams.set('limit', `${table.getState().pagination.pageSize}`);
return newParams;
},
{ replace: true },
);
}, [currentCursor, table.getState().pagination.pageSize]);
useEffect(() => {
setTableData(
tableData.filter(
(row) => !deletedRows.some((deletedRow) => deletedRow.id === row.id),
),
);
}, [deletedRows]);
const resetSelection = () => {
table.toggleAllRowsSelected(false);
};
return (
<div>
{((filters && filters.length > 0) || bulkActions.length > 0) && (
<DataTableToolbar>
<div className="w-full flex items-center justify-between">
<div className="flex items-center space-x-2">
{filters &&
filters.map((filter) => (
<DataTableFilter
key={filter.accessorKey}
column={table.getColumn(filter.accessorKey)}
{...filter}
/>
))}
{customFilters &&
customFilters.map((filter, idx) => (
<React.Fragment key={idx}>{filter}</React.Fragment>
))}
</div>
{bulkActions.length > 0 && (
<DataTableBulkActions
selectedRows={table
.getSelectedRowModel()
.rows.map((row) => row.original)}
actions={bulkActions.map((action) => ({
render: (selectedRows: RowDataWithActions<TData>[]) =>
action.render(selectedRows, resetSelection),
}))}
/>
)}
</div>
</DataTableToolbar>
)}
<div className="rounded-md mt-0 overflow-hidden">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="hover:bg-transparent">
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow className="hover:bg-background">
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
<DataTableSkeleton />
</TableCell>
</TableRow>
) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
className={cn('cursor-pointer', {
'hover:bg-background cursor-default': isNil(onRowClick),
})}
onClick={(e) => {
// Check if the clicked cell is not clickable
const clickedCellIndex = (e.target as HTMLElement).closest(
'td',
)?.cellIndex;
if (
clickedCellIndex !== undefined &&
columns[clickedCellIndex]?.notClickable
) {
return; // Don't trigger onRowClick for not clickable columns
}
onRowClick?.(row.original, e.ctrlKey, e);
}}
onAuxClick={(e) => {
// Similar check for auxiliary click (e.g., middle mouse button)
const clickedCellIndex = (e.target as HTMLElement).closest(
'td',
)?.cellIndex;
if (
clickedCellIndex !== undefined &&
columns[clickedCellIndex]?.notClickable
) {
return;
}
onRowClick?.(row.original, true, e);
}}
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
<div
className={cn('flex items-center', {
'justify-end': cell.column.id === 'actions',
'justify-start': cell.column.id !== 'actions',
})}
>
<div
onClick={(e) => {
if (cell.column.id === 'select') {
e.preventDefault();
e.stopPropagation();
return;
}
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</div>
</div>
</TableCell>
))}
</TableRow>
))
) : (
<TableRow className="hover:bg-background">
<TableCell
colSpan={columns.length}
className="h-[350px] text-center"
>
<div className="flex flex-col items-center justify-center gap-2">
{emptyStateIcon ? emptyStateIcon : <></>}
<p className="text-lg font-semibold">
{emptyStateTextTitle}
</p>
{emptyStateTextDescription && (
<p className="text-sm text-muted-foreground ">
{emptyStateTextDescription}
</p>
)}
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{!hidePagination && (
<div className="flex items-center justify-end space-x-2 py-4">
<p className="text-sm font-medium">Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
setCurrentCursor(undefined);
}}
>
<SelectTrigger className="h-9 min-w-[70px] w-auto">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 30, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentCursor(previousPageCursor)}
disabled={!previousPageCursor}
>
{t('Previous')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setCurrentCursor(nextPageCursor);
}}
disabled={!nextPageCursor}
>
{t('Next')}
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,355 @@
import { format, subDays, addDays, startOfDay, endOfDay } from 'date-fns';
import { t } from 'i18next';
import { Calendar as CalendarIcon, Clock } from 'lucide-react';
import * as React from 'react';
import { DateRange } from 'react-day-picker';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { Separator } from './separator';
import { TimePicker } from './time-picker';
export type PresetKey =
| '7days'
| '14days'
| '30days'
| '90days'
| '7'
| '14'
| '30'
| '90';
type DateTimePickerWithRangeProps = {
onChange: (date: DateRange | undefined) => void;
className?: string;
from?: string;
to?: string;
maxDate?: Date;
minDate?: Date;
presetType: 'past' | 'future';
defaultSelectedRange?: PresetKey;
};
const applyTimeToDate = (timeDate: Date, targetDate: Date): Date => {
const d = new Date(targetDate);
d.setHours(
timeDate.getHours(),
timeDate.getMinutes(),
timeDate.getSeconds(),
timeDate.getMilliseconds(),
);
return d;
};
const getDayBoundaries = () => {
const now = new Date();
return {
from: startOfDay(now),
to: endOfDay(now),
};
};
const PRESETS: Record<PresetKey, () => { from: Date; to: Date }> = {
'7days': () => ({ from: subDays(new Date(), 7), to: new Date() }),
'14days': () => ({ from: subDays(new Date(), 14), to: new Date() }),
'30days': () => ({ from: subDays(new Date(), 30), to: new Date() }),
'90days': () => ({ from: subDays(new Date(), 90), to: new Date() }),
'7': () => ({ from: new Date(), to: addDays(new Date(), 7) }),
'14': () => ({ from: new Date(), to: addDays(new Date(), 14) }),
'30': () => ({ from: new Date(), to: addDays(new Date(), 30) }),
'90': () => ({ from: new Date(), to: addDays(new Date(), 90) }),
};
const getPresetLabel = (value: string) => {
const labels: Record<string, string> = {
'7days': t('Last 7 Days'),
'14days': t('Last 14 Days'),
'30days': t('Last 30 Days'),
'90days': t('Last 90 Days'),
'7': t('Next 7 days'),
'14': t('Next 14 days'),
'30': t('Next 30 days'),
'90': t('Next 90 days'),
};
return labels[value] || '';
};
const detectPreset = (
from?: Date,
to?: Date,
presetType?: 'past' | 'future',
): string | null => {
if (!from || !to) return null;
const candidates =
presetType === 'past'
? (['7days', '14days', '30days', '90days'] as PresetKey[])
: (['7', '14', '30', '90'] as PresetKey[]);
for (const key of candidates) {
const { from: pf, to: pt } = PRESETS[key]();
if (
startOfDay(pf).getTime() === startOfDay(from).getTime() &&
endOfDay(pt).getTime() === endOfDay(to).getTime()
) {
return key;
}
}
return null;
};
const getDefaultRange = (presetKey: PresetKey) => {
const preset = PRESETS[presetKey]();
preset.from!.setHours(0, 0, 0, 0);
preset.to!.setHours(23, 59, 59, 999);
return preset;
};
const getInitialDateAndPreset = (
fromProp?: string,
toProp?: string,
presetType: 'past' | 'future' = 'past',
defaultPresetKey?: PresetKey,
): { initialDate: DateRange | undefined; initialPreset: string | null } => {
let initialDate: DateRange | undefined;
let initialPreset: string | null = null;
if (fromProp && toProp) {
initialDate = {
from: new Date(fromProp),
to: new Date(toProp),
};
initialPreset = detectPreset(initialDate.from, initialDate.to, presetType);
} else if (defaultPresetKey) {
initialDate = getDefaultRange(defaultPresetKey);
initialPreset = defaultPresetKey;
}
return { initialDate, initialPreset };
};
export function DateTimePickerWithRange({
className,
onChange,
from,
to,
maxDate = new Date(),
minDate,
presetType = 'past',
defaultSelectedRange,
}: DateTimePickerWithRangeProps) {
const { initialDate, initialPreset } = React.useMemo(() => {
return getInitialDateAndPreset(from, to, presetType, defaultSelectedRange);
}, [from, to, presetType, defaultSelectedRange]);
const [date, setDate] = React.useState<DateRange | undefined>(initialDate);
const [timeDate, setTimeDate] = React.useState<DateRange>({
from: initialDate?.from,
to: initialDate?.to,
});
const [selectedPreset, setSelectedPreset] = React.useState<string | null>(
initialPreset,
);
const isDefaultApplied = React.useRef(!!initialPreset && !from && !to);
React.useEffect(() => {
if (isDefaultApplied.current && date) {
onChange(date);
isDefaultApplied.current = false;
}
}, [date, onChange]);
React.useEffect(() => {
if (from && to) {
const newDate: DateRange = { from: new Date(from), to: new Date(to) };
setDate(newDate);
setTimeDate({ from: newDate.from, to: newDate.to });
const preset = detectPreset(newDate.from, newDate.to, presetType);
setSelectedPreset(preset);
} else if (!from && !to) {
setDate(initialDate);
setTimeDate({ from: initialDate?.from, to: initialDate?.to });
setSelectedPreset(initialPreset);
}
}, [from, to, presetType, initialDate, initialPreset]);
const handleSelect = (selectedDate: DateRange | undefined) => {
setSelectedPreset(null);
if (!selectedDate) {
setDate(undefined);
onChange(undefined);
return;
}
const newDate = {
from: selectedDate.from
? applyTimeToDate(
timeDate.from || getDayBoundaries().from,
selectedDate.from,
)
: undefined,
to: selectedDate.to
? applyTimeToDate(timeDate.to || getDayBoundaries().to, selectedDate.to)
: undefined,
};
setDate(newDate);
onChange(newDate);
};
const handlePresetChange = (value: string) => {
const newRange = PRESETS[value as PresetKey]();
newRange.from!.setHours(0, 0, 0, 0);
newRange.to!.setHours(23, 59, 59, 999);
setDate(newRange);
setTimeDate({ from: newRange.from, to: newRange.to });
setSelectedPreset(value);
onChange(newRange);
};
return (
<div className={cn('grid gap-2', className)}>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'min-w-[90px] border-dashed justify-start text-left font-normal',
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{selectedPreset ? (
<span>{getPresetLabel(selectedPreset)}</span>
) : date?.from ? (
date.to ? (
<div className="flex gap-2 items-center">
<div>{format(date.from, 'LLL dd, y, hh:mm a')}</div>
<div>{t('to')}</div>
<div>{format(date.to, 'LLL dd, y, hh:mm a')}</div>
</div>
) : (
format(date.from, 'LLL dd, y, hh:mm a')
)
) : (
<span>{t('Pick a date range')}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start">
<div className="flex space-x-2 mb-2">
<Select
onValueChange={handlePresetChange}
value={selectedPreset || undefined}
>
<SelectTrigger>
<SelectValue placeholder={t('Select preset')} />
</SelectTrigger>
<SelectContent>
{presetType === 'past' ? (
<>
<SelectItem value="7days">{t('Last 7 Days')}</SelectItem>
<SelectItem value="14days">{t('Last 14 Days')}</SelectItem>
<SelectItem value="30days">{t('Last 30 Days')}</SelectItem>
<SelectItem value="90days">{t('Last 90 Days')}</SelectItem>
</>
) : (
<>
<SelectItem value="7">{t('Next 7 days')}</SelectItem>
<SelectItem value="14">{t('Next 14 days')}</SelectItem>
<SelectItem value="30">{t('Next 30 days')}</SelectItem>
<SelectItem value="90">{t('Next 90 days')}</SelectItem>
</>
)}
</SelectContent>
</Select>
</div>
<Calendar
initialFocus
mode="range"
defaultMonth={date?.from}
selected={date}
onSelect={handleSelect}
numberOfMonths={2}
weekStartsOn={1}
toDate={maxDate}
fromDate={minDate}
/>
<Separator className="mb-4" />
<div className="flex gap-1.5 px-2 items-center text-sm mb-3">
<Clock className="w-4 h-4 text-muted-foreground" />
{t('Select Time Range')}
</div>
<div className="flex gap-3 items-center px-2 mb-2">
<TimePicker
date={timeDate.from}
name="from"
setDate={(fromTime) => {
const fromWithTime = applyTimeToDate(
fromTime,
date?.from ?? new Date(),
);
const updated = { from: fromWithTime, to: date?.to };
setDate(updated);
setTimeDate({ ...timeDate, from: fromTime });
setSelectedPreset(null);
onChange(updated);
}}
/>
{t('to')}
<TimePicker
date={timeDate.to}
name="to"
setDate={(toTime) => {
const toWithTime = applyTimeToDate(
toTime,
date?.to ?? date?.from ?? new Date(),
);
const updated = { from: date?.from, to: toWithTime };
setDate(updated);
setTimeDate({ ...timeDate, to: toTime });
setSelectedPreset(null);
onChange(updated);
}}
/>
</div>
<div className="flex justify-center mt-3">
<Button
variant="ghost"
size="sm"
className="text-primary hover:text-primary! w-full"
onClick={() => {
setDate(undefined);
setTimeDate({ from: undefined, to: undefined });
setSelectedPreset(null);
onChange(undefined);
}}
>
{t('Clear')}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -0,0 +1,137 @@
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
tabIndex={-1}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 outline-hidden',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
withCloseButton?: boolean;
showOverlay?: boolean;
}
>(
(
{
className,
children,
withCloseButton = true,
showOverlay = true,
...props
},
ref,
) => (
<DialogPortal>
{showOverlay && <DialogOverlay />}
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] outline-hidden z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] g4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
>
{children}
{withCloseButton && (
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<X className="size-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
),
);
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 gap-3 text-center sm:text-left mb-4',
className,
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 mt-4',
className,
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -0,0 +1,40 @@
import { cva, type VariantProps } from 'class-variance-authority';
import React from 'react';
import { cn } from '@/lib/utils';
const dotVariants = cva('size-2 rounded-full', {
variants: {
variant: {
destructive: 'bg-destructive',
primary: 'bg-primary',
},
},
defaultVariants: {},
});
interface DotProps
extends VariantProps<typeof dotVariants>,
React.HTMLAttributes<HTMLDivElement> {
animation?: boolean;
}
const Dot = React.forwardRef<HTMLDivElement, DotProps>(
({ className, animation = false, variant, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
dotVariants({ variant }),
animation && 'animate-pulse',
className,
)}
{...props}
/>
);
},
);
Dot.displayName = 'Dot';
export { Dot };

View File

@@ -0,0 +1,157 @@
'use client';
import * as React from 'react';
import { Drawer as DrawerPrimitive } from 'vaul';
import { cn } from '@/lib/utils';
interface DrawerContentProps
extends React.ComponentProps<typeof DrawerPrimitive.Content> {
fullscreen?: boolean;
}
function Drawer({
onOpenChange,
open,
closeOnEscape = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root> & {
closeOnEscape?: boolean;
}) {
React.useEffect(() => {
if (!open || !onOpenChange || !closeOnEscape) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && closeOnEscape) {
onOpenChange(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open, onOpenChange, closeOnEscape]);
return (
<DrawerPrimitive.Root
data-slot="drawer"
open={open}
onOpenChange={onOpenChange}
{...props}
/>
);
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerContent({
className,
children,
fullscreen = false,
...props
}: DrawerContentProps) {
return (
<DrawerPortal data-slot="drawer-portal">
{fullscreen && (
<style>
{`
[data-vaul-drawer][data-vaul-drawer-direction="right"]::after {
display: none !important;
}
`}
</style>
)}
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
'group/drawer-content bg-background fixed z-50 flex h-auto flex-col shadow-lg outline-hidden select-text',
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b',
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t',
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:shadow-[-10px_0_10px_-3px_rgba(0,0,0,0.1)]',
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:shadow-[10px_0_10px_-3px_rgba(0,0,0,0.1)]',
fullscreen && 'w-screen max-w-none',
className,
)}
{...props}
>
{!fullscreen && (
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
)}
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="drawer-header"
className={cn(
'flex flex-col border border-b gap-0.5 p-0 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left',
className,
)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="drawer-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn('text-foreground font-semibold', className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@@ -0,0 +1,240 @@
'use client';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:outline-hidden focus-visible:outline-hidden focus:bg-accent data-[state=open]:bg-accent',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-32 overflow-hidden rounded-md border bg-background p-1 text-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=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',
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
/**This is needed because animation out changes the focus after the animation is done leading into race conditions
i.e when an item is clicked, the menu closes and the item is focused, but the animation is not complete yet
so the item is not focused and the menu is open
so we need to disable the animation on out
*/
noAnimationOnOut?: boolean;
}
>(({ className, sideOffset = 4, noAnimationOnOut = false, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-32 overflow-hidden rounded-md border bg-background p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=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',
{ 'data-[state=closed]:animate-out ': !noAnimationOnOut },
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:outline-hidden focus-visible:outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:outline-hidden focus-visible:outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:outline-hidden focus-visible:outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
type DropdownMenuShortcutProps = React.HTMLAttributes<HTMLSpanElement> & {
keyboardShortcut: string;
onKeyboardShortcut: () => void;
};
const DropdownMenuShortcut = ({
className,
keyboardShortcut,
onKeyboardShortcut,
...props
}: DropdownMenuShortcutProps) => {
React.useEffect(() => {
if (keyboardShortcut) {
document.addEventListener('keydown', handleKeyDown);
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [keyboardShortcut]);
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === keyboardShortcut?.toLocaleLowerCase() &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
event.stopPropagation();
if (onKeyboardShortcut) {
onKeyboardShortcut();
}
}
};
return (
<span
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props}
>
{keyboardShortcut.toString().toLocaleUpperCase()}
</span>
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@@ -0,0 +1,136 @@
import { useState, useRef, useCallback } from 'react';
import { isNil } from '@activepieces/shared';
import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip';
type EditableTextProps = {
value: string | undefined;
className?: string;
readonly: boolean;
onValueChange: (value: string) => void;
tooltipContent?: string;
disallowEditingOnClick?: boolean;
isEditing: boolean;
setIsEditing: (isEditing: boolean) => void;
};
const EditableText = ({
value: initialValue,
className = '',
readonly = false,
onValueChange,
tooltipContent,
disallowEditingOnClick,
isEditing,
setIsEditing,
}: EditableTextProps) => {
const [value, setValue] = useState(initialValue);
const isEditingPreviousRef = useRef(false);
const valueOnEditingStartedRef = useRef(initialValue);
if (value !== initialValue) {
setValue(initialValue);
}
const editableTextRef = useRef<HTMLDivElement>(null);
const emitChangedValue = useCallback(() => {
const nodeValue = (editableTextRef.current?.textContent ?? '').trim();
const shouldUpdateValue =
nodeValue.length > 0 && nodeValue !== valueOnEditingStartedRef.current;
setValue(shouldUpdateValue ? nodeValue : valueOnEditingStartedRef.current);
if (shouldUpdateValue) {
onValueChange(nodeValue);
}
}, [onValueChange, valueOnEditingStartedRef.current]);
const setSelectionToValue = () => {
requestAnimationFrame(() => {
if (
editableTextRef.current &&
window.getSelection &&
document.createRange
) {
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(editableTextRef.current);
sel?.removeAllRanges();
sel?.addRange(range);
}
});
};
if (isEditing && !isEditingPreviousRef.current) {
valueOnEditingStartedRef.current = value ? value.trim() : '';
setSelectionToValue();
}
isEditingPreviousRef.current = isEditing;
return !isEditing ? (
<Tooltip>
<TooltipTrigger
disabled={
readonly ||
isEditing ||
disallowEditingOnClick ||
isNil(tooltipContent)
}
asChild
>
<div
onClick={() => {
if (!isEditing && !readonly && !disallowEditingOnClick) {
setIsEditing(true);
}
}}
ref={editableTextRef}
key={'viewed'}
className={`${className} truncate `}
title={
editableTextRef.current &&
editableTextRef.current.scrollWidth >
editableTextRef.current.clientWidth &&
value
? value
: ''
}
>
{value}
</div>
</TooltipTrigger>
{tooltipContent && (
<TooltipContent className="font-normal z-50" side="bottom">
{tooltipContent}
</TooltipContent>
)}
</Tooltip>
) : (
<div
key={'editable'}
ref={editableTextRef}
contentEditable
suppressContentEditableWarning={true}
className={`${className} focus:outline-hidden break-all`}
onBlur={() => {
emitChangedValue();
setIsEditing(false);
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setValue(valueOnEditingStartedRef.current);
setIsEditing(false);
} else if (event.key === 'Enter') {
emitChangedValue();
setIsEditing(false);
}
}}
>
{value}
</div>
);
};
EditableText.displayName = 'EditableText';
export default EditableText;

View File

@@ -0,0 +1,104 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
function Empty({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty"
className={cn(
'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12',
className,
)}
{...props}
/>
);
}
function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty-header"
className={cn(
'flex max-w-sm flex-col items-center gap-2 text-center',
className,
)}
{...props}
/>
);
}
const emptyMediaVariants = cva(
'flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-transparent',
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: 'default',
},
},
);
function EmptyMedia({
className,
variant = 'default',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
);
}
function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty-title"
className={cn('text-lg font-medium tracking-tight', className)}
{...props}
/>
);
}
function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<div
data-slot="empty-description"
className={cn(
'text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
className,
)}
{...props}
/>
);
}
function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty-content"
className={cn(
'flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance',
className,
)}
{...props}
/>
);
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
};

View File

@@ -0,0 +1,248 @@
'use client';
import { cva, type VariantProps } from 'class-variance-authority';
import { useMemo } from 'react';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
return (
<fieldset
data-slot="field-set"
className={cn(
'flex flex-col gap-6',
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
className,
)}
{...props}
/>
);
}
function FieldLegend({
className,
variant = 'legend',
...props
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
'mb-3 font-medium',
'data-[variant=legend]:text-base',
'data-[variant=label]:text-sm',
className,
)}
{...props}
/>
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-group"
className={cn(
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4',
className,
)}
{...props}
/>
);
}
const fieldVariants = cva(
'group/field flex w-full gap-3 data-[invalid=true]:text-destructive',
{
variants: {
orientation: {
vertical: ['flex-col *:w-full [&>.sr-only]:w-auto'],
horizontal: [
'flex-row items-center',
'*:data-[slot=field-label]:flex-auto',
'has-[>[data-slot=field-content]]:items-start [&>[role=checkbox],[role=radio]]:has-[>[data-slot=field-content]]:mt-px',
],
responsive: [
'flex-col *:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto',
'@md/field-group:*:data-[slot=field-label]:flex-auto',
'@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:[&>[role=checkbox],[role=radio]]:has-[>[data-slot=field-content]]:mt-px',
],
},
},
defaultVariants: {
orientation: 'vertical',
},
},
);
function Field({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
}
function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-content"
className={cn(
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
className,
)}
{...props}
/>
);
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border data-[slot=field]:*:p-4',
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
className,
)}
{...props}
/>
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-label"
className={cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
className,
)}
{...props}
/>
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="field-description"
className={cn(
'text-muted-foreground text-sm leading-normal font-normal group-has-data-[orientation=horizontal]/field:text-balance',
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
className,
)}
{...props}
/>
);
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<'div'> & {
children?: React.ReactNode;
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
className,
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
);
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<'div'> & {
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children;
}
if (!errors?.length) {
return null;
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
];
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message;
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>,
)}
</ul>
);
}, [children, errors]);
if (!content) {
return null;
}
return (
<div
role="alert"
data-slot="field-error"
className={cn('text-destructive text-sm font-normal', className)}
{...props}
>
{content}
</div>
);
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
};

View File

@@ -0,0 +1,196 @@
'use client';
import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import * as React from 'react';
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from 'react-hook-form';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
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, formState } = useFormContext();
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,
);
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('space-y-1', className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = 'FormItem';
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && 'text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = 'FormLabel';
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = 'FormControl';
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
);
});
FormDescription.displayName = 'FormDescription';
const FormError = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement> & {
formMessageId: string;
}
>(({ className, children, ...props }, ref) => {
return (
<p
ref={ref}
id={props.formMessageId}
className={cn(
'text-sm font-medium text-destructive wrap-break-word',
className,
)}
{...props}
>
{children}
</p>
);
});
FormError.displayName = 'FormError';
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return <></>;
}
return (
<FormError formMessageId={formMessageId} {...props} ref={ref}>
{body}
</FormError>
);
});
FormMessage.displayName = 'FormMessage';
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
FormError,
};

View File

@@ -0,0 +1,38 @@
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { formatUtils } from '@/lib/utils';
type FormattedDateProps = {
date: Date;
includeTime?: boolean | undefined;
className?: string;
};
export const FormattedDate = ({
date,
includeTime,
className,
}: FormattedDateProps) => {
const formattedDate = formatUtils.formatDate(date);
const formattedDateWithTime = formatUtils.formatDateWithTime(date, false);
const fullDateTimeTooltip = formatUtils.formatDateWithTime(date, true);
return (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span className={className}>
{includeTime ? formattedDateWithTime : formattedDate}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{fullDateTimeTooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};

View File

@@ -0,0 +1,19 @@
import { t } from 'i18next';
import { flagsHooks } from '@/hooks/flags-hooks';
const FullLogo = () => {
const branding = flagsHooks.useWebsiteBranding();
return (
<div className="h-[60px]">
<img
className="h-full"
src={branding.logos.fullLogoUrl}
alt={t('logo')}
/>
</div>
);
};
FullLogo.displayName = 'FullLogo';
export { FullLogo };

View File

@@ -0,0 +1,75 @@
import { t } from 'i18next';
import { ChevronLeft } from 'lucide-react';
import { Link } from 'react-router-dom';
import { flagsHooks } from '@/hooks/flags-hooks';
import { authenticationSession } from '@/lib/authentication-session';
import { ActivepiecesClientEventName } from 'ee-embed-sdk';
import { useEmbedding } from '../embed-provider';
import { Button } from './button';
import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip';
const HomeButtonWrapper = ({ children }: { children: React.ReactNode }) => {
const { embedState } = useEmbedding();
if (embedState.emitHomeButtonClickedEvent) {
const handleClick = () => {
window.parent.postMessage(
{
type: ActivepiecesClientEventName.CLIENT_BUILDER_HOME_BUTTON_CLICKED,
data: {
route: '/flows',
},
},
'*',
);
};
return <div onClick={handleClick}>{children}</div>;
}
return (
<Link to={authenticationSession.appendProjectRoutePrefix('/flows')}>
{children}
</Link>
);
};
const HomeButton = () => {
const { embedState } = useEmbedding();
const branding = flagsHooks.useWebsiteBranding();
const showBackButton = embedState.homeButtonIcon === 'back';
return (
<>
{!embedState.hideHomeButtonInBuilder && (
<Tooltip>
<HomeButtonWrapper>
<TooltipTrigger asChild>
<Button
variant="ghost"
size={'icon'}
className={showBackButton ? 'size-8' : 'size-10'}
>
{!showBackButton && (
<img
className="h-5 w-5 object-contain"
src={branding.logos.logoIconUrl}
alt={branding.websiteName}
/>
)}
{showBackButton && <ChevronLeft className="h-4 w-4" />}
</Button>
</TooltipTrigger>
</HomeButtonWrapper>
{!showBackButton && (
<TooltipContent side="bottom">
{t('Go to Dashboard')}
</TooltipContent>
)}
</Tooltip>
)}
</>
);
};
HomeButton.displayName = 'HomeButton';
export { HomeButton };

View File

@@ -0,0 +1,131 @@
import { FastAverageColor } from 'fast-average-color';
import React, { useState, useEffect, useRef } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
interface ImageWithColorBackgroundProps
extends React.ImgHTMLAttributes<HTMLImageElement> {
fallback?: React.ReactNode;
border?: boolean;
}
const isGrayColor = (r: number, g: number, b: number): boolean => {
const threshold = 15;
const darkThreshold = 150;
const lightThreshold = 225;
const isDark = r <= darkThreshold && g <= darkThreshold && b <= darkThreshold;
const isLight =
r >= lightThreshold && g >= lightThreshold && b >= lightThreshold;
const diffRG = Math.abs(r - g);
const diffRB = Math.abs(r - b);
const diffGB = Math.abs(g - b);
const isGray =
diffRG <= threshold && diffRB <= threshold && diffGB <= threshold;
return isDark || isLight || isGray;
};
const ImageWithColorBackground = ({
src,
alt,
fallback,
...props
}: ImageWithColorBackgroundProps) => {
const [hasError, setHasError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [backgroundColor, setBackgroundColor] = useState<string | null>(null);
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
if (!src) return;
const fac = new FastAverageColor();
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = src;
img.onload = () => {
fac
.getColorAsync(img, { algorithm: 'simple' })
.then((color) => {
const [r, g, b] = color.value;
if (isGrayColor(r, g, b)) {
setBackgroundColor(null);
} else {
setBackgroundColor(
`color-mix(in srgb, rgb(${r},${g},${b}) 10%, #fff 92%)`,
);
}
})
.catch(() => {
setBackgroundColor(null);
});
};
return () => {
fac.destroy();
};
}, [src]);
const handleLoad = () => {
setIsLoading(false);
};
const handleError = () => {
setHasError(true);
setIsLoading(false);
};
const { className, ...rest } = props;
return (
<span
className={cn(
'relative inline-block h-full w-full rounded-lg',
className,
{
'bg-background': backgroundColor === null,
'border border-border/50 dark:bg-foreground/10':
backgroundColor === null && props.border,
},
)}
style={
backgroundColor
? {
backgroundColor: backgroundColor,
}
: {}
}
>
{isLoading && !hasError && (
<span className="absolute inset-0 flex items-center justify-center">
{fallback ?? <Skeleton className="w-full h-full" />}
</span>
)}
{!hasError ? (
<img
ref={imgRef}
src={src}
alt={alt}
onLoad={handleLoad}
onError={handleError}
className={cn(
`transition-opacity duration-500 w-full h-full object-contain`,
{
'opacity-0': isLoading,
'opacity-100': !isLoading,
},
)}
{...rest}
/>
) : (
<span className="absolute inset-0 flex items-center justify-center">
{fallback ?? <Skeleton className="w-full h-full" />}
</span>
)}
</span>
);
};
export { ImageWithColorBackground };

View File

@@ -0,0 +1,63 @@
import React, { useState } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
interface ImageWithFallbackProps
extends React.ImgHTMLAttributes<HTMLImageElement> {
fallback?: React.ReactNode;
}
const ImageWithFallback = ({
src,
alt,
fallback,
...props
}: ImageWithFallbackProps) => {
const [hasError, setHasError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const handleLoad = () => {
setIsLoading(false);
};
const handleError = () => {
setHasError(true);
setIsLoading(false);
};
const { className, ...rest } = props;
return (
<span className={cn('relative inline-block h-full w-full', className)}>
{isLoading && !hasError && (
<span className="absolute inset-0 flex items-center justify-center">
{fallback ?? <Skeleton className="w-full h-full" />}
</span>
)}
{!hasError ? (
<img
src={src}
alt={alt}
onLoad={handleLoad}
onError={handleError}
className={cn(
`transition-opacity duration-500 w-full h-full object-cover`,
{
'opacity-0': isLoading,
'opacity-100': !isLoading,
},
className,
)}
{...rest}
/>
) : (
<span className="absolute inset-0 flex items-center justify-center">
{fallback ?? <Skeleton className="w-full h-full" />}
</span>
)}
</span>
);
};
export default ImageWithFallback;

View File

@@ -0,0 +1,75 @@
import { t } from 'i18next';
import { Paperclip } from 'lucide-react';
import * as React from 'react';
import { useImperativeHandle } from 'react';
import { cn } from '@/lib/utils';
import { SelectUtilButton } from '../custom/select-util-button';
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
thin?: boolean;
defaultFileName?: string;
};
export const inputClass =
'flex h-9 w-full rounded-md border border-input-border bg-background px-3 py-1 text-base shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm';
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, thin = false, defaultFileName, ...props }, ref) => {
const [fileName, setFileName] = React.useState<string | null>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
setFileName(file ? file.name : null);
props.onChange?.(event);
};
useImperativeHandle(ref, () => inputRef.current!);
const handleDivClick = () => {
inputRef.current?.click();
};
return type === 'file' ? (
<>
<input
type="file"
className="hidden"
ref={inputRef}
{...props}
onChange={handleFileChange}
/>
<div
onClick={handleDivClick}
className={cn(inputClass, 'cursor-pointer items-center ', className)}
>
<input
className={cn('grow cursor-pointer outline-hidden bg-transparent', {
'text-muted-foreground': !fileName,
})}
value={fileName || defaultFileName || t('Select a file')}
readOnly
/>
<div className="basis-1">
<SelectUtilButton
onClick={(e) => e.preventDefault()}
tooltipText={fileName ? fileName : t('Select a file')}
Icon={Paperclip}
></SelectUtilButton>
</div>
</div>
</>
) : (
<input
type={type}
className={cn(inputClass, className, {
'h-8 p-2': thin,
})}
ref={inputRef}
{...props}
/>
);
},
);
Input.displayName = 'Input';
export { Input };

View File

@@ -0,0 +1,193 @@
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
role="list"
data-slot="item-group"
className={cn('group/item-group flex flex-col', className)}
{...props}
/>
);
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn('my-0', className)}
{...props}
/>
);
}
const itemVariants = cva(
'group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
{
variants: {
variant: {
default: 'bg-transparent',
outline: 'border-border',
muted: 'bg-muted/50',
},
size: {
default: 'p-4 gap-4 ',
sm: 'py-3 px-4 gap-2.5',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
function Item({
className,
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<'div'> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div';
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
);
}
const itemMediaVariants = cva(
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5',
{
variants: {
variant: {
default: 'bg-transparent',
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
image:
'size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function ItemMedia({
className,
variant = 'default',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
);
}
function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-content"
className={cn(
'flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none',
className,
)}
{...props}
/>
);
}
function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-title"
className={cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium',
className,
)}
{...props}
/>
);
}
function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="item-description"
className={cn(
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
className,
)}
{...props}
/>
);
}
function ItemActions({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-actions"
className={cn('flex items-center gap-2', className)}
{...props}
/>
);
}
function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-header"
className={cn(
'flex basis-full items-center justify-between gap-2',
className,
)}
{...props}
/>
);
}
function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-footer"
className={cn(
'flex basis-full items-center justify-between gap-2',
className,
)}
{...props}
/>
);
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
};

View File

@@ -0,0 +1,26 @@
'use client';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -0,0 +1,27 @@
import { cn } from '../../lib/utils';
import { LoadingSpinner } from './spinner';
type LoadingScreenProps = {
brightSpinner?: boolean;
mode?: 'fullscreen' | 'container';
};
export const LoadingScreen = ({
brightSpinner = false,
mode = 'fullscreen',
}: LoadingScreenProps) => {
return (
<div
className={cn('flex h-screen w-screen items-center justify-center', {
'h-full w-full': mode === 'container',
})}
>
<LoadingSpinner
className={cn({
'stroke-background!': brightSpinner,
})}
isLarge={true}
></LoadingSpinner>
</div>
);
};

View File

@@ -0,0 +1,30 @@
import { Lock } from 'lucide-react';
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
interface LockedAlertProps {
title: string;
description: string;
button: React.ReactNode;
}
export const LockedAlert = ({
title,
description,
button,
}: LockedAlertProps) => {
return (
<Alert className="flex items-center gap-4 mb-4">
<div className="flex items-start gap-3">
<Lock className="h-5 w-5 text-primary-600 mt-1" />
<div>
<AlertTitle className="font-semibold text-lg">{title}</AlertTitle>
<AlertDescription className="text-sm text-muted-foreground">
{description}
</AlertDescription>
</div>
</div>
<div className="ml-auto">{button}</div>
</Alert>
);
};

View File

@@ -0,0 +1,26 @@
import { t } from 'i18next';
import React from 'react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
export const MessageTooltip = React.forwardRef<
HTMLButtonElement,
{ children: React.ReactNode; isDisabled: boolean; message: string }
>(({ children, isDisabled, message }, ref) => {
return (
<Tooltip delayDuration={100}>
<TooltipTrigger ref={ref} asChild>
<div>{children}</div>
</TooltipTrigger>
{isDisabled && (
<TooltipContent side="bottom">{t(message)}</TooltipContent>
)}
</Tooltip>
);
});
MessageTooltip.displayName = 'MessageTooltip';

View File

@@ -0,0 +1,76 @@
import { MinusIcon, PlusIcon } from 'lucide-react';
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
interface NumberInputWithButtonsProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
className?: string;
}
const NumberInputWithButtons: React.FC<NumberInputWithButtonsProps> = ({
value,
onChange,
min = 0,
max = Infinity,
step = 1,
disabled = false,
className = '',
}) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = parseInt(e.target.value) || 0;
onChange(Math.min(Math.max(newValue, min), max));
};
const increment = () => {
if (disabled) return;
onChange(Math.min(value + step, max));
};
const decrement = () => {
if (disabled) return;
onChange(Math.max(value - step, min));
};
return (
<div className={`flex items-center ${className}`}>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-r-none"
onClick={decrement}
disabled={disabled || value <= min}
type="button"
>
<MinusIcon className="h-3.5 w-3.5" />
</Button>
<Input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={value}
onChange={handleChange}
className="h-8 w-16 text-center rounded-none border-x-0"
disabled={disabled}
/>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-l-none"
onClick={increment}
disabled={disabled || value >= max}
type="button"
>
<PlusIcon className="h-3.5 w-3.5" />
</Button>
</div>
);
};
export default NumberInputWithButtons;

View File

@@ -0,0 +1,31 @@
import * as PopoverPrimitive from '@radix-ui/react-popover';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-md border bg-background p-4 text-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=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',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -0,0 +1,26 @@
import * as ProgressPrimitive from '@radix-ui/react-progress';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
'relative h-4 w-full overflow-hidden rounded-full bg-accent',
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@@ -0,0 +1,37 @@
import * as ProgressPrimitive from '@radix-ui/react-progress';
import * as React from 'react';
import { cn } from '@/lib/utils';
type ProgressProps = React.ComponentProps<typeof ProgressPrimitive.Root> & {
indicatorClassName?: string;
};
function Progress({
className,
value,
indicatorClassName,
...props
}: ProgressProps) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
'bg-muted relative h-2 w-full overflow-hidden rounded-full',
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn(
'bg-primary h-full w-full flex-1 transition-all',
indicatorClassName,
)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@@ -0,0 +1,45 @@
'use client';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import { CircleIcon } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn('grid gap-3', className)}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-2xs transition-[color,box-shadow] outline-hidden focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };

View File

@@ -0,0 +1,49 @@
import { useState } from 'react';
interface ReadMoreProps {
text: string;
amountOfCharacters?: number;
}
export const ReadMoreDescription = ({
text,
amountOfCharacters = 80,
}: ReadMoreProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const itCanOverflow = text.length > amountOfCharacters;
const beginText = itCanOverflow ? text.slice(0, amountOfCharacters) : text;
const endText = text.slice(amountOfCharacters);
const handleKeyboard = (e: { code: string }) => {
if (e.code === 'Space' || e.code === 'Enter') {
setIsExpanded(!isExpanded);
}
};
return (
<p className="text-muted-foreground text-xs whitespace-pre-wrap">
{beginText}
{itCanOverflow && (
<>
{!isExpanded && <span>... </span>}
<span
className={`${!isExpanded && 'hidden'} whitespace-pre-wrap`}
aria-hidden={!isExpanded}
>
{endText}
</span>
<span
className="text-primary ml-2 cursor-pointer"
role="button"
tabIndex={0}
aria-expanded={isExpanded}
onKeyDown={handleKeyboard}
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? 'show less' : 'show more'}
</span>
</>
)}
</p>
);
};

View File

@@ -0,0 +1,46 @@
'use client';
import { GripVertical } from 'lucide-react';
import * as React from 'react';
import * as ResizablePrimitive from 'react-resizable-panels';
import { cn } from '@/lib/utils';
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
className,
)}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
'bg-border z-40 focus-visible:ring-ring relative flex w-[1px] items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-[2px] data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90',
className,
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVertical className="size-2.5 hover:fill-primary" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@@ -0,0 +1,114 @@
'use client';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import * as React from 'react';
import { cn } from '@/lib/utils';
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
viewPortClassName?: string;
orientation?: 'vertical' | 'horizontal';
viewPortRef?: React.RefObject<HTMLDivElement>;
showGradient?: boolean;
gradientClassName?: string;
}
>(
(
{
className,
children,
viewPortClassName,
viewPortRef,
orientation = 'vertical',
showGradient = false,
gradientClassName,
...props
},
ref,
) => {
const [showBottomGradient, setShowBottomGradient] = React.useState(false);
const internalViewPortRef = React.useRef<HTMLDivElement>(null);
const viewportRef = viewPortRef || internalViewPortRef;
React.useEffect(() => {
if (!showGradient || !viewportRef.current) return;
const viewport = viewportRef.current;
const checkScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = viewport;
const hasScrollableContent = scrollHeight > clientHeight;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1;
setShowBottomGradient(hasScrollableContent && !isAtBottom);
};
checkScroll();
viewport.addEventListener('scroll', checkScroll);
const resizeObserver = new ResizeObserver(checkScroll);
if (viewport.firstElementChild) {
resizeObserver.observe(viewport.firstElementChild);
}
return () => {
viewport.removeEventListener('scroll', checkScroll);
resizeObserver.disconnect();
};
}, [showGradient, viewportRef]);
return (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
className={cn(
'size-full rounded-[inherit] [&>div]:block!',
viewPortClassName,
)}
ref={viewportRef}
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar orientation={orientation} />
<ScrollAreaPrimitive.Corner />
{showGradient && showBottomGradient && (
<div
className={cn(
'pointer-events-none absolute bottom-0 left-0 right-0 h-1/5 bg-linear-to-t from-sidebar to-transparent',
gradientClassName,
)}
/>
)}
</ScrollAreaPrimitive.Root>
);
},
);
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent p-px',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent p-px',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@@ -0,0 +1,44 @@
import { t } from 'i18next';
import { Search, X } from 'lucide-react';
import * as React from 'react';
import { SelectUtilButton } from '../custom/select-util-button';
export type SearchInputProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'onChange'
> & {
onChange: (value: string) => void;
};
const SearchInput = React.forwardRef<HTMLInputElement, SearchInputProps>(
({ type, ...props }, ref) => {
return (
<div className="grow flex items-center gap-2 w-full bg-background px-3 focus-within:outline-hidden first:disabled:cursor-not-allowed first:disabled:opacity-50 box-border">
<Search className="size-4 shrink-0 opacity-50"></Search>
<input
className="rounded-md bg-transparent h-9 grow text-sm outline-hidden placeholder:text-muted-foreground"
type={type}
ref={ref}
{...props}
onChange={(e) => props.onChange(e.target.value)}
data-testid="pieces-search-input"
/>
{props.value !== '' && (
<SelectUtilButton
tooltipText={t('Clear')}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
props.onChange('');
}}
Icon={X}
></SelectUtilButton>
)}
</div>
);
},
);
SearchInput.displayName = 'SearchInput';
export { SearchInput };

View File

@@ -0,0 +1,193 @@
import {
CaretSortIcon,
ChevronDownIcon,
ChevronUpIcon,
} from '@radix-ui/react-icons';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs ring-offset-background placeholder:text-muted-foreground focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<CaretSortIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-background text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=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',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectAction = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & {
children: React.ReactNode;
disabled?: boolean;
}
>(({ className, children, disabled, ...props }, ref) => (
<div
ref={ref}
className={cn(
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-hidden hover:bg-accent hover:text-accent-foreground',
className,
{ 'text-muted-foreground cursor-not-allowed': disabled },
)}
{...props}
onClick={(e) => {
if (disabled) {
e.preventDefault();
e.stopPropagation();
} else {
props.onClick?.(e);
}
}}
>
{children}
</div>
));
SelectAction.displayName = 'SelectAction';
export { SelectAction };
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@@ -0,0 +1,48 @@
'use client';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = 'horizontal', decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
type HorizontalSeparatorWithTextProps = React.HTMLAttributes<HTMLDivElement> & {
children: React.ReactNode;
};
const HorizontalSeparatorWithText = React.forwardRef<
HTMLDivElement,
HorizontalSeparatorWithTextProps
>(({ className, ...props }, ref) => (
<div className={cn('flex w-full flex-row items-center', className)}>
<div className="w-1/2 border" />
<span className="mx-2 text-sm">{props.children}</span>
<div className="w-1/2 border" />
</div>
));
HorizontalSeparatorWithText.displayName = 'HorizontalSeparatorWithText';
export { HorizontalSeparatorWithText };

View File

@@ -0,0 +1,149 @@
'use client';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out',
{
variants: {
side: {
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom:
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right:
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
},
},
defaultVariants: {
side: 'right',
},
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {
hideCloseButton?: boolean;
}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(
(
{ side = 'right', className, children, hideCloseButton = false, ...props },
ref,
) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{!hideCloseButton && (
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
{children}
</SheetPrimitive.Content>
</SheetPortal>
),
);
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className,
)}
{...props}
/>
);
SheetHeader.displayName = 'SheetHeader';
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
)}
{...props}
/>
);
SheetFooter.displayName = 'SheetFooter';
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-foreground', className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@@ -0,0 +1,39 @@
import { cn } from '@/lib/utils';
const toTitleCase = (str: string) => {
return str
.toLowerCase()
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
export type ShortcutProps = {
//can't name it key because it conflicts with the key prop of the component
shortcutKey: string;
withCtrl?: boolean;
withShift?: boolean;
shouldNotPreventDefault?: boolean;
};
export const Shortcut = ({
shortcutKey,
withCtrl,
withShift,
className,
}: ShortcutProps & { className?: string }) => {
const isMac = /(Mac)/i.test(navigator.userAgent);
const isEscape = shortcutKey?.toLocaleLowerCase() === 'esc';
return (
<span
className={cn(
'grow text-xs tracking-widest text-muted-foreground',
className,
)}
>
{!isEscape && withCtrl && (isMac ? '⌘' : 'Ctrl')}
{!isEscape && withShift && 'Shift'}
{!isEscape && (withCtrl || withShift) && ' + '}
{shortcutKey && toTitleCase(shortcutKey)}
</span>
);
};

View File

@@ -0,0 +1,831 @@
import { Slot } from '@radix-ui/react-slot';
import { VariantProps, cva } from 'class-variance-authority';
import { t } from 'i18next';
import { PanelLeft } from 'lucide-react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Skeleton } from '@/components/ui/skeleton';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useIsMobile } from '@/hooks/use-mobile';
import { cn } from '@/lib/utils';
const SIDEBAR_WIDTH = '18rem';
const SIDEBAR_WIDTH_MOBILE = '18rem';
const SIDEBAR_WIDTH_ICON = '3rem';
const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
const SIDEBAR_STATE_STORAGE_KEY = 'sidebar-state';
function getSidebarStateFromLocalStorage() {
const stored = localStorage.getItem(SIDEBAR_STATE_STORAGE_KEY);
return stored ? stored === 'true' : true;
}
function setSidebarStateToLocalStorage(isOpen: boolean) {
localStorage.setItem(SIDEBAR_STATE_STORAGE_KEY, isOpen.toString());
}
type SidebarContextProps = {
state: 'expanded' | 'collapsed';
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider.');
}
return context;
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
>(
(
{
defaultOpen,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref,
) => {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
const [_open, _setOpen] = React.useState(
defaultOpen ?? getSidebarStateFromLocalStorage(),
);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === 'function' ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
setSidebarStateToLocalStorage(openState);
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed';
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
'group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar',
className,
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
},
);
SidebarProvider.displayName = 'SidebarProvider';
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & {
side?: 'left' | 'right';
variant?: 'sidebar' | 'floating' | 'inset';
collapsible?: 'offcanvas' | 'icon' | 'none';
}
>(
(
{
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
className,
children,
...props
},
ref,
) => {
const { isMobile, state, openMobile, setOpenMobile, setOpen } =
useSidebar();
if (collapsible === 'none') {
return (
<div
className={cn(
'flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground',
className,
)}
ref={ref}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
)}
/>
<div
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]'
: 'group-data-[collapsible=icon]:w-[--sidebar-width-icon]',
state === 'collapsed' &&
collapsible === 'icon' &&
'[&_*]:!cursor-nesw-resize [&_button]:!cursor-pointer [&_button]:relative [&_button]:z-20 [&_button_*]:!cursor-pointer [&_a]:!cursor-pointer [&_a]:relative [&_a]:z-20 [&_a_*]:!cursor-pointer [&_[role=button]]:!cursor-pointer [&_[role=button]]:relative [&_[role=button]]:z-20 [&_[role=button]_*]:!cursor-pointer [&_[data-sidebar=menu-button]]:!cursor-pointer [&_[data-sidebar=menu-button]]:relative [&_[data-sidebar=menu-button]]:z-20 [&_[data-sidebar=menu-button]_*]:!cursor-pointer cursor-nesw-resize',
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
className={cn(
'flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm',
state === 'collapsed' && collapsible === 'icon' && 'relative',
)}
>
{/* Clickable overlay to expand collapsed sidebar */}
{state === 'collapsed' && collapsible === 'icon' && (
<div
className="absolute inset-0 !cursor-nesw-resize z-10"
onClick={() => setOpen(true)}
aria-hidden="true"
/>
)}
{children}
</div>
</div>
</div>
);
},
);
Sidebar.displayName = 'Sidebar';
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button> & { iconClassName?: string }
>(({ className, onClick, iconClassName, ...props }, ref) => {
const { toggleSidebar, state } = useSidebar();
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn(className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft></PanelLeft>
<span className="sr-only">{t('Toggle Sidebar')}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{state === 'expanded' ? t('Close Sidebar') : t('Open Sidebar')}
</TooltipContent>
</Tooltip>
);
});
SidebarTrigger.displayName = 'SidebarTrigger';
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<'button'>
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className,
)}
{...props}
/>
);
});
SidebarRail.displayName = 'SidebarRail';
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'main'>
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
'relative flex w-full flex-1 flex-col bg-background max-h-[calc(100vh)]',
'md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:border-l',
className,
)}
{...props}
/>
);
});
SidebarInset.displayName = 'SidebarInset';
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
'h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring',
className,
)}
{...props}
/>
);
});
SidebarInput.displayName = 'SidebarInput';
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
);
});
SidebarHeader.displayName = 'SidebarHeader';
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
);
});
SidebarFooter.displayName = 'SidebarFooter';
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn('mx-2 w-auto bg-sidebar-border', className)}
{...props}
/>
);
});
SidebarSeparator.displayName = 'SidebarSeparator';
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
className,
)}
{...props}
/>
);
});
SidebarContent.displayName = 'SidebarContent';
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn(
'relative flex w-full min-w-0 flex-col px-2 py-0',
className,
)}
{...props}
/>
);
});
SidebarGroup.displayName = 'SidebarGroup';
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'div';
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
'flex h-8 shrink-0 items-center rounded-sm px-2 text-xs font-medium text-sidebar-foreground/70 outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
className,
)}
{...props}
/>
);
});
SidebarGroupLabel.displayName = 'SidebarGroupLabel';
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<'button'> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
'absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-sm p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
);
});
SidebarGroupAction.displayName = 'SidebarGroupAction';
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn('w-full text-sm', className)}
{...props}
/>
));
SidebarGroupContent.displayName = 'SidebarGroupContent';
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
{...props}
/>
));
SidebarMenu.displayName = 'SidebarMenu';
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<'li'>
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn('group/menu-item relative', className)}
{...props}
/>
));
SidebarMenuItem.displayName = 'SidebarMenuItem';
const sidebarMenuButtonVariants = cva(
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-sm text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
{
variants: {
variant: {
default:
'hover:bg-sidebar-accent active:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<'button'> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = 'default',
size = 'default',
tooltip,
className,
...props
},
ref,
) => {
const Comp = asChild ? Slot : 'button';
const { isMobile, state } = useSidebar();
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === 'string') {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== 'collapsed' || isMobile}
{...tooltip}
/>
</Tooltip>
);
},
);
SidebarMenuButton.displayName = 'SidebarMenuButton';
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<'button'> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
'absolute right-1 top-2 flex aspect-square w-5 items-center justify-center rounded-sm p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
showOnHover &&
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0',
className,
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = 'SidebarMenuAction';
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
'pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-sm px-1 text-xs font-medium tabular-nums text-sidebar-foreground',
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
));
SidebarMenuBadge.displayName = 'SidebarMenuBadge';
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & {
showIcon?: boolean;
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn('flex h-8 items-center gap-2 rounded-sm px-2', className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-sm"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
'--skeleton-width': width,
} as React.CSSProperties
}
/>
</div>
);
});
SidebarMenuSkeleton.displayName = 'SidebarMenuSkeleton';
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
'ml-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border pl-2.5',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
));
SidebarMenuSub.displayName = 'SidebarMenuSub';
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<'li'>
>(({ ...props }, ref) => <li ref={ref} {...props} />);
SidebarMenuSubItem.displayName = 'SidebarMenuSubItem';
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<'a'> & {
asChild?: boolean;
size?: 'sm' | 'md';
isActive?: boolean;
}
>(({ asChild = false, size = 'md', isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : 'a';
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-sm px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
size === 'sm' && 'text-xs',
size === 'md' && 'text-sm',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = 'SidebarMenuSubButton';
const SidebarSkeleton = ({ numOfItems }: { numOfItems: number }) => {
return (
<SidebarMenu>
{[...Array(numOfItems)].map((_, index) => (
<SidebarMenuItem key={index}>
<SidebarMenuButton disabled className="px-2">
<div className="flex items-center w-full">
<div className="w-4 h-4 mr-2 bg-muted rounded animate-pulse" />
<div className="bg-muted w-full rounded animate-pulse h-4" />
</div>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
);
};
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarSkeleton,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@@ -0,0 +1,37 @@
import { cn } from '@/lib/utils';
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('animate-pulse rounded-md bg-muted/50', className)}
{...props}
/>
);
}
function SkeletonList({
className,
numberOfItems = 3,
...props
}: React.HTMLAttributes<HTMLDivElement> & {
numberOfItems?: number;
}) {
const array = Array(numberOfItems).fill(null);
return (
<div className="space-y-3">
{array.map((_, index) => (
<Skeleton
key={index}
className={cn('h-4 w-full', className)}
{...props}
/>
))}
</div>
);
}
SkeletonList.displayName = 'SkeletonList';
Skeleton.displayName = 'Skeleton';
export { Skeleton, SkeletonList };

View File

@@ -0,0 +1,26 @@
import * as SliderPrimitive from '@radix-ui/react-slider';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
'relative flex w-full touch-none select-none items-center',
className,
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow-sm transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@@ -0,0 +1,74 @@
'use client';
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from 'lucide-react';
import { Toaster as Sonner, toast, type ToasterProps } from 'sonner';
import { useTheme } from '@/components/theme-provider';
export const INTERNAL_ERROR_MESSAGE =
'An unexpected error occurred. Please try again in a moment.';
export function internalErrorToast() {
toast.error('Something went wrong', {
description: INTERNAL_ERROR_MESSAGE,
duration: 3000,
});
}
export const UNSAVED_CHANGES_TOAST = {
id: 'unsaved-changes',
title: 'Unsaved Changes',
description:
'Something went wrong and there are unsaved changes, please refresh and contact support if the problem persists.',
variant: 'destructive',
duration: Infinity,
};
const Toaster = ({ ...props }: ToasterProps) => {
const { theme } = useTheme();
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
toastOptions={{
classNames: {
toast: `
data-[type=error]:text-destructive-300!
data-[type=warning]:text-warning-300!
data-[type=success]:text-success-300!
`,
description: `
data-[type=error]:text-destructive-300!
data-[type=warning]:text-warning-300!
data-[type=success]:text-success-300!
`,
},
descriptionClassName: 'text-inherit!',
}}
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
'--normal-text': 'var(--foreground)',
'--normal-bg': 'var(--background)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };

View File

@@ -0,0 +1,338 @@
'use client';
import type {
DndContextProps,
DraggableSyntheticListeners,
DropAnimation,
UniqueIdentifier,
} from '@dnd-kit/core';
import {
closestCenter,
defaultDropAnimationSideEffects,
DndContext,
DragOverlay,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
restrictToHorizontalAxis,
restrictToParentElement,
restrictToVerticalAxis,
} from '@dnd-kit/modifiers';
import {
arrayMove,
horizontalListSortingStrategy,
SortableContext,
useSortable,
verticalListSortingStrategy,
type SortableContextProps,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Slot, type SlotProps } from '@radix-ui/react-slot';
import * as React from 'react';
import { Button, type ButtonProps } from '@/components/ui/button';
import { composeRefs } from '@/lib/compose-refs';
import { cn } from '@/lib/utils';
const orientationConfig = {
vertical: {
modifiers: [restrictToVerticalAxis, restrictToParentElement],
strategy: verticalListSortingStrategy,
},
horizontal: {
modifiers: [restrictToHorizontalAxis, restrictToParentElement],
strategy: horizontalListSortingStrategy,
},
mixed: {
modifiers: [restrictToParentElement],
strategy: undefined,
},
};
interface SortableProps<TData extends { id: UniqueIdentifier }>
extends DndContextProps {
/**
* An array of data items that the sortable component will render.
* @example
* value={[
* { id: 1, name: 'Item 1' },
* { id: 2, name: 'Item 2' },
* ]}
*/
value: TData[];
/**
* An optional callback function that is called when the order of the data items changes.
* It receives the new array of items as its argument.
* @example
* onValueChange={(items) => console.log(items)}
*/
onValueChange?: (items: TData[]) => void;
/**
* An optional callback function that is called when an item is moved.
* It receives an event object with `activeIndex` and `overIndex` properties, representing the original and new positions of the moved item.
* This will override the default behavior of updating the order of the data items.
* @type (event: { activeIndex: number; overIndex: number }) => void
* @example
* onMove={(event) => console.log(`Item moved from index ${event.activeIndex} to index ${event.overIndex}`)}
*/
onMove?: (event: { activeIndex: number; overIndex: number }) => void;
/**
* A collision detection strategy that will be used to determine the closest sortable item.
* @default closestCenter
* @type DndContextProps["collisionDetection"]
*/
collisionDetection?: DndContextProps['collisionDetection'];
/**
* An array of modifiers that will be used to modify the behavior of the sortable component.
* @default
* [restrictToVerticalAxis, restrictToParentElement]
* @type Modifier[]
*/
modifiers?: DndContextProps['modifiers'];
/**
* A sorting strategy that will be used to determine the new order of the data items.
* @default verticalListSortingStrategy
* @type SortableContextProps["strategy"]
*/
strategy?: SortableContextProps['strategy'];
/**
* Specifies the axis for the drag-and-drop operation. It can be "vertical", "horizontal", or "both".
* @default "vertical"
* @type "vertical" | "horizontal" | "mixed"
*/
orientation?: 'vertical' | 'horizontal' | 'mixed';
/**
* An optional React node that is rendered on top of the sortable component.
* It can be used to display additional information or controls.
* @default null
* @type React.ReactNode | null
* @example
* overlay={<Skeleton className="w-full h-8" />}
*/
overlay?: React.ReactNode | null;
}
function Sortable<TData extends { id: UniqueIdentifier }>({
value,
onValueChange,
collisionDetection = closestCenter,
modifiers,
strategy,
onMove,
orientation = 'vertical',
overlay,
children,
...props
}: SortableProps<TData>) {
const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null);
const sensors = useSensors(
useSensor(MouseSensor),
useSensor(TouchSensor),
useSensor(KeyboardSensor),
);
const config = orientationConfig[orientation];
return (
<DndContext
modifiers={modifiers ?? config.modifiers}
sensors={sensors}
onDragStart={({ active }) => setActiveId(active.id)}
onDragEnd={({ active, over }) => {
if (over && active.id !== over?.id) {
const activeIndex = value.findIndex((item) => item.id === active.id);
const overIndex = value.findIndex((item) => item.id === over.id);
if (onMove) {
onMove({ activeIndex, overIndex });
} else {
onValueChange?.(arrayMove(value, activeIndex, overIndex));
}
}
setActiveId(null);
}}
onDragCancel={() => setActiveId(null)}
collisionDetection={collisionDetection}
{...props}
>
<SortableContext items={value} strategy={strategy ?? config.strategy}>
{children}
</SortableContext>
{overlay ? (
<SortableOverlay activeId={activeId}>{overlay}</SortableOverlay>
) : null}
</DndContext>
);
}
const dropAnimationOpts: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: '0.4',
},
},
}),
};
interface SortableOverlayProps
extends React.ComponentPropsWithRef<typeof DragOverlay> {
activeId?: UniqueIdentifier | null;
}
const SortableOverlay = React.forwardRef<HTMLDivElement, SortableOverlayProps>(
(
{ activeId, dropAnimation = dropAnimationOpts, children, ...props },
ref,
) => {
return (
<DragOverlay dropAnimation={dropAnimation} {...props}>
{activeId ? (
<SortableItem
ref={ref}
value={activeId}
className="cursor-grabbing"
asChild
>
{children}
</SortableItem>
) : null}
</DragOverlay>
);
},
);
SortableOverlay.displayName = 'SortableOverlay';
interface SortableItemContextProps {
attributes: React.HTMLAttributes<HTMLElement>;
listeners: DraggableSyntheticListeners | undefined;
isDragging?: boolean;
}
const SortableItemContext = React.createContext<SortableItemContextProps>({
attributes: {},
listeners: undefined,
isDragging: false,
});
function useSortableItem() {
const context = React.useContext(SortableItemContext);
if (!context) {
throw new Error('useSortableItem must be used within a SortableItem');
}
return context;
}
interface SortableItemProps extends SlotProps {
/**
* The unique identifier of the item.
* @example "item-1"
* @type UniqueIdentifier
*/
value: UniqueIdentifier;
/**
* Specifies whether the item should act as a trigger for the drag-and-drop action.
* @default false
* @type boolean | undefined
*/
asTrigger?: boolean;
/**
* Merges the item's props into its immediate child.
* @default false
* @type boolean | undefined
*/
asChild?: boolean;
}
/** Child must be a div */
const SortableItem = React.forwardRef<HTMLDivElement, SortableItemProps>(
({ value, asTrigger, asChild, className, ...props }, ref) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: value });
const context = React.useMemo<SortableItemContextProps>(
() => ({
attributes,
listeners,
isDragging,
}),
[attributes, listeners, isDragging],
);
const style: React.CSSProperties = {
opacity: isDragging ? 0.5 : 1,
transform: CSS.Translate.toString(transform),
transition,
};
const Comp = asChild ? Slot : 'div';
return (
<SortableItemContext.Provider value={context}>
<Comp
data-state={isDragging ? 'dragging' : undefined}
className={cn(
'data-[state=dragging]:cursor-grabbing',
{ 'cursor-grab': !isDragging && asTrigger },
className,
)}
ref={composeRefs(ref, setNodeRef as React.Ref<HTMLDivElement>)}
style={style}
{...(asTrigger ? attributes : {})}
{...(asTrigger ? listeners : {})}
{...props}
/>
</SortableItemContext.Provider>
);
},
);
SortableItem.displayName = 'SortableItem';
interface SortableDragHandleProps extends ButtonProps {
withHandle?: boolean;
}
const SortableDragHandle = React.forwardRef<
HTMLButtonElement,
SortableDragHandleProps
>(({ className, ...props }, ref) => {
const { attributes, listeners, isDragging } = useSortableItem();
return (
<Button
ref={composeRefs(ref)}
data-state={isDragging ? 'dragging' : undefined}
className={cn(
'cursor-grab data-[state=dragging]:cursor-grabbing',
className,
)}
type="button"
{...attributes}
{...listeners}
{...props}
/>
);
});
SortableDragHandle.displayName = 'SortableDragHandle';
export { Sortable, SortableDragHandle, SortableItem, SortableOverlay };

View File

@@ -0,0 +1,29 @@
import { LoaderCircle } from 'lucide-react';
import React from 'react';
import { cn } from '@/lib/utils';
export interface ISVGProps extends React.SVGProps<SVGSVGElement> {
className?: string;
isLarge?: boolean;
}
/**When editing the size of the spinner use size class */
const LoadingSpinner = React.memo(
({ className, isLarge = false }: ISVGProps) => {
return (
<LoaderCircle
className={cn(
'animate-spin duration-1500 stroke-foreground size-5',
{
'size-[24px]': !isLarge,
'size-[50px]': isLarge,
},
className,
)}
/>
);
},
);
LoadingSpinner.displayName = 'LoadingSpinner';
export { LoadingSpinner };

View File

@@ -0,0 +1,60 @@
import { cva, type VariantProps } from 'class-variance-authority';
import React from 'react';
import { isNil } from '@activepieces/shared';
const statusCodeVariants = cva(
'inline-flex gap-1 rounded px-2.5 py-1 text-xs font-semibold',
{
variants: {
variant: {
success: 'bg-success-100 text-success-300',
error: 'bg-destructive-100 text-destructive-300',
default: 'bg-accent text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
interface StatusIconWithTextProps
extends VariantProps<typeof statusCodeVariants> {
icon: any;
text: string;
color?: string;
textColor?: string;
}
const StatusIconWithText = React.memo(
({
icon: Icon,
text,
variant,
color,
textColor,
}: StatusIconWithTextProps) => {
if (isNil(color) || isNil(textColor)) {
return (
<span className={statusCodeVariants({ variant })}>
<Icon className="size-4" />
<span>{text}</span>
</span>
);
}
return (
<span
className={statusCodeVariants({ variant })}
style={{ backgroundColor: color || undefined }}
>
<Icon className="size-4" style={{ color: textColor }} />
<span style={{ color: textColor }}>{text}</span>
</span>
);
},
);
StatusIconWithText.displayName = 'StatusIconWithText';
export { StatusIconWithText };

View File

@@ -0,0 +1,105 @@
import * as SwitchPrimitives from '@radix-ui/react-switch';
import * as React from 'react';
import { cn } from '../../lib/utils';
export interface SwitchProps
extends React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> {
checkedIcon?: React.ReactNode;
uncheckedIcon?: React.ReactNode;
variant?: 'default' | 'square';
size?: 'default' | 'sm' | 'lg' | 'xl';
color?: 'default' | 'secondary';
}
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
SwitchProps
>(
(
{
className,
checkedIcon,
uncheckedIcon,
onCheckedChange,
variant = 'default',
size = 'default',
color = 'default',
...props
},
ref,
) => {
const isControlled = props.checked !== undefined;
const [internalChecked, setInternalChecked] = React.useState(
props.defaultChecked ?? false,
);
const isChecked = isControlled ? props.checked : internalChecked;
const handleCheckedChange = (checked: boolean) => {
if (!isControlled) {
setInternalChecked(checked);
}
if (onCheckedChange) {
onCheckedChange(checked);
}
};
const effectiveCheckedIcon = checkedIcon;
const effectiveUncheckedIcon = uncheckedIcon || checkedIcon;
const icon = isChecked ? effectiveCheckedIcon : effectiveUncheckedIcon;
const sizeClasses = {
sm: 'h-4 w-8',
default: 'h-5 w-10',
lg: 'h-7 w-14',
xl: 'h-8 w-16',
};
const thumbSizeClasses = {
sm: 'h-3 w-3 data-[state=checked]:translate-x-4',
default: 'h-4 w-4 data-[state=checked]:translate-x-5',
lg: 'h-5 w-5 data-[state=checked]:translate-x-6',
xl: 'h-6 w-6 data-[state=checked]:translate-x-7',
};
const colorClasses: Record<NonNullable<SwitchProps['color']>, string> = {
default:
'data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
secondary:
'data-[state=checked]:bg-secondary data-[state=unchecked]:bg-input',
};
return (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex shrink-0 cursor-pointer items-center border-2 border-transparent transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
colorClasses[color],
variant === 'square' ? 'rounded-md' : 'rounded-full',
sizeClasses[size],
className,
)}
onCheckedChange={handleCheckedChange}
{...props}
checked={isChecked}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none flex items-center justify-center bg-background shadow-lg ring-0 transition-transform data-[state=unchecked]:translate-x-0',
variant === 'square' ? 'rounded-sm' : 'rounded-full',
thumbSizeClasses[size],
// thumbColorClasses[color]
)}
>
{icon && (
<span className="flex items-center justify-center">{icon}</span>
)}
</SwitchPrimitives.Thumb>
</SwitchPrimitives.Root>
);
},
);
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@@ -0,0 +1,117 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
));
Table.displayName = 'Table';
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
));
TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
));
TableBody.displayName = 'TableBody';
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
'border-t bg-muted/50 font-medium last:[&>tr]:border-b-0',
className,
)}
{...props}
/>
));
TableFooter.displayName = 'TableFooter';
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
className,
)}
{...props}
/>
));
TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className,
)}
{...props}
/>
));
TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
));
TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn('mt-4 text-sm text-muted-foreground', className)}
{...props}
/>
));
TableCaption.displayName = 'TableCaption';
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@@ -0,0 +1,82 @@
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Tabs = TabsPrimitive.Root;
const tabsListVariants = cva('inline-flex ', {
variants: {
variant: {
default:
'items-center justify-center h-10 rounded-md bg-muted p-1 text-muted-foreground',
outline: '',
// Add more variants here
},
},
defaultVariants: {
variant: 'default',
},
});
const tabsTriggerVariants = cva('inline-flex items-center justify-center', {
variants: {
variant: {
default:
'whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs',
outline:
'px-3 py-1.5 text-sm font-medium ring-offset-background transition-all border-b-2 border-transparent data-[state=active]:border-secondary data-[state=active]:text-foreground text-muted-foreground',
},
},
defaultVariants: {
variant: 'default',
},
});
interface TabsListProps
extends React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>,
VariantProps<typeof tabsListVariants> {}
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
TabsListProps
>(({ className, variant, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(tabsListVariants({ variant, className }))}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
interface TabsTriggerProps
extends React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>,
VariantProps<typeof tabsTriggerVariants> {}
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
TabsTriggerProps
>(({ className, variant, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(tabsTriggerVariants({ variant, className }))}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-5 ring-offset-background focus-visible:outline-hidden',
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,110 @@
'use client';
import { t } from 'i18next';
import { XIcon } from 'lucide-react';
import { forwardRef, useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
import { Badge } from './badge';
import { Button } from './button';
import type { InputProps } from './input';
import { ReadMoreDescription } from './read-more-description';
type TagInputProps = Omit<InputProps, 'value' | 'onChange'> & {
value?: ReadonlyArray<string>;
onChange: (value: ReadonlyArray<string>) => void;
};
const SEPARATOR = ' ';
const TagInput = forwardRef<HTMLInputElement, TagInputProps>((props, ref) => {
const { className, value = [], onChange, ...domProps } = props;
const [pendingDataPoint, setPendingDataPoint] = useState('');
useEffect(() => {
if (pendingDataPoint.includes(SEPARATOR)) {
const newDataPoints = new Set(
[...value, ...pendingDataPoint.split(SEPARATOR)].flatMap((x) => {
const trimmedX = x.trim();
return trimmedX.length > 0 ? [trimmedX] : [];
}),
);
onChange(Array.from(newDataPoints));
setPendingDataPoint('');
}
}, [pendingDataPoint, onChange, value]);
const addPendingDataPoint = () => {
if (pendingDataPoint) {
const newDataPoints = new Set(
[...value, ...pendingDataPoint.split(SEPARATOR)].flatMap((x) => {
const trimmedX = x.trim();
return trimmedX.length > 0 ? [trimmedX] : [];
}),
);
onChange(Array.from(newDataPoints));
setPendingDataPoint('');
}
};
return (
<>
<div
className={cn(
// caveat: :has() variant requires tailwind v3.4 or above: https://tailwindcss.com/blog/tailwindcss-v3-4#new-has-variant
'has-focus-visible:ring-neutral-950 dark:has-focus-visible:ring-neutral-300 border-neutral-200 dark:border-neutral-800 dark:bg-neutral-950 dark:ring-offset-neutral-950 flex min-h-10 w-full flex-wrap gap-2 rounded-md border bg-white px-3 py-2 text-sm ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 has-focus-visible:outline-hidden has-focus-visible:ring-2 has-focus-visible:ring-offset-2',
className,
)}
>
{value.map((item) => (
<Badge key={item} variant={'accent'}>
<span className="text-xs font-medium">{item}</span>
<Button
variant={'ghost'}
size={'icon'}
className={'ml-2 h-3 w-3'}
onClick={() => {
onChange(value.filter((i) => i !== item));
}}
>
<XIcon className={'w-3'} />
</Button>
</Badge>
))}
<input
className={
'placeholder:text-neutral-500 dark:placeholder:text-neutral-400 flex-1 outline-hidden'
}
value={pendingDataPoint}
onChange={(e) => setPendingDataPoint(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === SEPARATOR) {
e.preventDefault();
addPendingDataPoint();
} else if (
e.key === 'Backspace' &&
pendingDataPoint.length === 0 &&
value.length > 0
) {
e.preventDefault();
onChange(value.slice(0, -1));
}
}}
{...domProps}
ref={ref}
/>
</div>
<div className="mt-3">
<ReadMoreDescription
text={t('Press space to separate values')}
></ReadMoreDescription>
</div>
</>
);
});
TagInput.displayName = 'TagInput';
export { TagInput };

View File

@@ -0,0 +1,19 @@
export function TextWithIcon({
icon,
text,
className = '',
children,
}: {
icon: React.ReactNode;
text: React.ReactNode;
children?: React.ReactNode;
className?: string;
}) {
return (
<div className={`flex items-center gap-2 ${className}`}>
{icon}
{text}
{children}
</div>
);
}

View File

@@ -0,0 +1,46 @@
import * as React from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import { cn } from '@/lib/utils';
type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
type Style = Omit<
NonNullable<TextareaProps['style']>,
'maxHeight' | 'minHeight'
> & {
height?: number;
};
type TextareaHeightChangeMeta = {
rowHeight: number;
};
interface TextareaAutosizeProps extends Omit<TextareaProps, 'style'> {
maxRows?: number;
minRows?: number;
onHeightChange?: (height: number, meta: TextareaHeightChangeMeta) => void;
cacheMeasurements?: boolean;
style?: Style;
}
export type ResizableTextareaProps = TextareaAutosizeProps &
React.RefAttributes<HTMLTextAreaElement>;
const Textarea = React.forwardRef<
HTMLTextAreaElement,
TextareaAutosizeProps & React.RefAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => {
return (
<TextareaAutosize
cacheMeasurements={false}
minRows={1}
maxRows={5}
className={cn(
'flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = 'Textarea';
export { Textarea };

View File

@@ -0,0 +1,87 @@
'use client';
import { t } from 'i18next';
import * as React from 'react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { Period, display12HourValue, setDateByType } from './time-picker-utils';
export interface PeriodSelectorProps {
period: Period;
setPeriod: (m: Period) => void;
date: Date | undefined;
setDate: (date: Date) => void;
onRightFocus?: () => void;
onLeftFocus?: () => void;
isActive: boolean;
}
export const TimePeriodSelect = React.forwardRef<
HTMLButtonElement,
PeriodSelectorProps
>(
(
{ period, setPeriod, date, setDate, onLeftFocus, onRightFocus, isActive },
ref,
) => {
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'ArrowRight') onRightFocus?.();
if (e.key === 'ArrowLeft') onLeftFocus?.();
};
const handleValueChange = (value: Period) => {
setPeriod(value);
/**
* trigger an update whenever the user switches between AM and PM;
* otherwise user must manually change the hour each time
*/
if (date) {
const tempDate = new Date(date);
const hours = display12HourValue(date.getHours());
setDate(
setDateByType(
tempDate,
hours.toString(),
'12hours',
period === 'AM' ? 'PM' : 'AM',
),
);
}
};
return (
<div className="flex h-10 items-center">
<Select
value={period}
onValueChange={(value: Period) => handleValueChange(value)}
>
<SelectTrigger
ref={ref}
className={cn(
' hover:bg-accent w-[73px] h-[29px] focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 rounded-xs justify-center p-0 transition-all border-none text-sm shadow-none gap-3 ',
{
'bg-background': isActive,
},
)}
onKeyDown={handleKeyDown}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AM">{t('AM')}</SelectItem>
<SelectItem value="PM">{t('PM')}</SelectItem>
</SelectContent>
</Select>
</div>
);
},
);
TimePeriodSelect.displayName = 'TimePeriodSelect';

View File

@@ -0,0 +1,204 @@
/**
* regular expression to check for valid hour format (01-23)
*/
export function isValidHour(value: string) {
return /^(0[0-9]|1[0-9]|2[0-3])$/.test(value);
}
/**
* regular expression to check for valid 12 hour format (01-12)
*/
export function isValid12Hour(value: string) {
return /^(0[1-9]|1[0-2])$/.test(value);
}
/**
* regular expression to check for valid minute format (00-59)
*/
export function isValidMinuteOrSecond(value: string) {
return /^[0-5][0-9]$/.test(value);
}
type GetValidNumberConfig = { max: number; min?: number; loop?: boolean };
export function getValidNumber(
value: string,
{ max, min = 0, loop = false }: GetValidNumberConfig,
) {
let numericValue = parseInt(value, 10);
if (!isNaN(numericValue)) {
if (!loop) {
if (numericValue > max) numericValue = max;
if (numericValue < min) numericValue = min;
} else {
if (numericValue > max) numericValue = min;
if (numericValue < min) numericValue = max;
}
return numericValue.toString().padStart(2, '0');
}
return '00';
}
export function getValidHour(value: string) {
if (isValidHour(value)) return value;
return getValidNumber(value, { max: 23 });
}
export function getValid12Hour(value: string) {
if (isValid12Hour(value)) return value;
return getValidNumber(value, { min: 1, max: 12 });
}
export function getValidMinuteOrSecond(value: string) {
if (isValidMinuteOrSecond(value)) return value;
return getValidNumber(value, { max: 59 });
}
type GetValidArrowNumberConfig = {
min: number;
max: number;
step: number;
};
export function getValidArrowNumber(
value: string,
{ min, max, step }: GetValidArrowNumberConfig,
) {
let numericValue = parseInt(value, 10);
if (!isNaN(numericValue)) {
numericValue += step;
return getValidNumber(String(numericValue), { min, max, loop: true });
}
return '00';
}
export function getValidArrowHour(value: string, step: number) {
return getValidArrowNumber(value, { min: 0, max: 23, step });
}
export function getValidArrow12Hour(value: string, step: number) {
return getValidArrowNumber(value, { min: 1, max: 12, step });
}
export function getValidArrowMinuteOrSecond(value: string, step: number) {
return getValidArrowNumber(value, { min: 0, max: 59, step });
}
export function setMinutes(date: Date, value: string) {
const minutes = getValidMinuteOrSecond(value);
date.setMinutes(parseInt(minutes, 10));
return date;
}
export function setSeconds(date: Date, value: string) {
const seconds = getValidMinuteOrSecond(value);
date.setSeconds(parseInt(seconds, 10));
return date;
}
export function setHours(date: Date, value: string) {
const hours = getValidHour(value);
date.setHours(parseInt(hours, 10));
return date;
}
export function set12Hours(date: Date, value: string, period: Period) {
const hours = parseInt(getValid12Hour(value), 10);
const convertedHours = convert12HourTo24Hour(hours, period);
date.setHours(convertedHours);
return date;
}
export type TimePickerType = 'minutes' | 'seconds' | 'hours' | '12hours';
export type Period = 'AM' | 'PM';
export function setDateByType(
date: Date,
value: string,
type: TimePickerType,
period?: Period,
) {
switch (type) {
case 'minutes':
return setMinutes(date, value);
case 'seconds':
return setSeconds(date, value);
case 'hours':
return setHours(date, value);
case '12hours': {
if (!period) return date;
return set12Hours(date, value, period);
}
default:
return date;
}
}
export function getDateByType(date: Date, type: TimePickerType) {
switch (type) {
case 'minutes':
return getValidMinuteOrSecond(String(date.getMinutes()));
case 'seconds':
return getValidMinuteOrSecond(String(date.getSeconds()));
case 'hours':
return getValidHour(String(date.getHours()));
case '12hours': {
const hours = display12HourValue(date.getHours());
return getValid12Hour(String(hours));
}
default:
return '00';
}
}
export function getArrowByType(
value: string,
step: number,
type: TimePickerType,
) {
switch (type) {
case 'minutes':
return getValidArrowMinuteOrSecond(value, step);
case 'seconds':
return getValidArrowMinuteOrSecond(value, step);
case 'hours':
return getValidArrowHour(value, step);
case '12hours':
return getValidArrow12Hour(value, step);
default:
return '00';
}
}
/**
* handles value change of 12-hour input
* 12:00 PM is 12:00
* 12:00 AM is 00:00
*/
export function convert12HourTo24Hour(hour: number, period: Period) {
if (period === 'PM') {
if (hour <= 11) {
return hour + 12;
} else {
return hour;
}
} else if (period === 'AM') {
if (hour === 12) return 0;
return hour;
}
return hour;
}
/**
* time is stored in the 24-hour form,
* but needs to be displayed to the user
* in its 12-hour representation
*/
export function display12HourValue(hours: number) {
if (hours === 0 || hours === 12) return '12';
if (hours >= 22) return `${hours - 12}`;
if (hours % 12 > 9) return `${hours}`;
return `0${hours % 12}`;
}

View File

@@ -0,0 +1,127 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { isNil } from '@activepieces/shared';
import { TimePeriodSelect } from './time-period-select';
import { Period } from './time-picker-utils';
import { TimeUnitPickerInput } from './time-unit-input';
interface TimePickerProps {
date: Date | undefined;
setDate: (date: Date) => void;
showSeconds?: boolean;
name?: string;
}
const minutesItems = new Array(60).fill(0).map((_, index) => ({
value: index.toString(),
label: index < 10 ? `0${index}` : index.toString(),
}));
const hoursItems = new Array(12).fill(0).map((_, index) => ({
value: (index + 1).toString(),
label: index + 1 < 10 ? `0${index + 1}` : (index + 1).toString(),
}));
export function TimePicker({
date,
setDate,
showSeconds,
name = 'from',
}: TimePickerProps) {
const [period, setPeriod] = React.useState<Period>(() => {
if (date) {
return date.getHours() >= 12 ? 'PM' : 'AM';
}
return name === 'from' ? 'AM' : 'PM';
});
React.useEffect(() => {
if (date && date.getHours() >= 12) {
setPeriod('PM');
} else if (!date) {
setPeriod(name === 'from' ? 'AM' : 'PM');
}
}, [date]);
const hasValueChanged =
name === 'from'
? date?.getHours() !== 0 || date?.getMinutes() !== 0 || period !== 'AM'
: date?.getHours() !== 23 || date?.getMinutes() !== 59 || period !== 'PM';
const isActive = !isNil(date) && hasValueChanged;
const minuteRef = React.useRef<HTMLInputElement>(null);
const hourRef = React.useRef<HTMLInputElement>(null);
const secondRef = React.useRef<HTMLInputElement>(null);
const periodRef = React.useRef<HTMLButtonElement>(null);
return (
<div
className={cn(
'flex items-center transition-all gap-2 w-full text-muted-foreground justify-center bg-accent/50 py-1 px-2 rounded-sm h-[43px] border border-solid border-border',
{
'text-foreground': isActive,
},
)}
>
<div className="grid gap-1 text-center">
<TimeUnitPickerInput
picker="12hours"
isActive={isActive}
period={period}
date={date}
setDate={setDate}
name={name}
ref={hourRef}
onRightFocus={() => minuteRef.current?.focus()}
autoCompleteList={hoursItems}
/>
</div>
:
<div className="grid gap-1 text-center">
<TimeUnitPickerInput
picker="minutes"
id="minutes12"
isActive={isActive}
name={name}
date={date}
period={period}
setDate={setDate}
ref={minuteRef}
onLeftFocus={() => hourRef.current?.focus()}
onRightFocus={() => secondRef.current?.focus()}
autoCompleteList={minutesItems}
/>
</div>
{showSeconds && (
<>
:
<div className="grid gap-1 text-center">
<TimeUnitPickerInput
picker="seconds"
id="seconds12"
name={name}
isActive={isActive}
date={date}
setDate={setDate}
ref={secondRef}
onLeftFocus={() => minuteRef.current?.focus()}
onRightFocus={() => periodRef.current?.focus()}
/>
</div>
</>
)}
<div className="grid gap-1 text-center">
<TimePeriodSelect
period={period}
isActive={isActive}
setPeriod={setPeriod}
date={date}
setDate={setDate}
ref={periodRef}
onLeftFocus={() => secondRef.current?.focus()}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,218 @@
import React, { useRef } from 'react';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { isNil } from '@activepieces/shared';
import { AutoComplete } from './autocomplete';
import {
Period,
TimePickerType,
getArrowByType,
getDateByType,
setDateByType,
} from './time-picker-utils';
export interface TimeUnitPickerInputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
picker: TimePickerType;
date: Date | undefined;
setDate: (date: Date) => void;
period?: Period;
onRightFocus?: () => void;
onLeftFocus?: () => void;
isActive: boolean;
autoCompleteList?: { value: string; label: string }[];
isAutocompleteOpen?: boolean;
name?: string;
}
const TimeUnitPickerInputInner = React.forwardRef<
HTMLInputElement,
TimeUnitPickerInputProps
>(
(
{
className,
type = 'tel',
value,
id,
name,
date = !name || name === 'from'
? new Date(new Date().setHours(0, 0, 0, 0))
: new Date(new Date().setHours(23, 59, 59, 999)),
setDate,
onChange,
onKeyDown,
picker,
period,
onLeftFocus,
onRightFocus,
isActive,
isAutocompleteOpen,
onClick,
},
ref,
) => {
const [flag, setFlag] = React.useState<boolean>(false);
const [prevIntKey, setPrevIntKey] = React.useState<string>('0');
/**
* allow the user to enter the second digit within 2 seconds
* otherwise start again with entering first digit
*/
React.useEffect(() => {
if (flag) {
const timer = setTimeout(() => {
setFlag(false);
}, 2000);
return () => clearTimeout(timer);
}
}, [flag]);
const calculatedValue = React.useMemo(() => {
return getDateByType(date, picker);
}, [date, picker]);
const calculateNewValue = (key: string) => {
/*
* If picker is '12hours' and the first digit is 0, then the second digit is automatically set to 1.
* The second entered digit will break the condition and the value will be set to 10-12.
*/
if (picker === '12hours') {
if (flag && calculatedValue.slice(1, 2) === '1' && prevIntKey === '0')
return '0' + key;
}
return !flag ? '0' + key : calculatedValue.slice(1, 2) + key;
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
onKeyDown?.(e);
if (e.key === 'Tab') return;
e.preventDefault();
if (e.key === 'ArrowRight') onRightFocus?.();
if (e.key === 'ArrowLeft') onLeftFocus?.();
if (['ArrowUp', 'ArrowDown'].includes(e.key) && !isAutocompleteOpen) {
const step = e.key === 'ArrowUp' ? 1 : -1;
const newValue = getArrowByType(calculatedValue, step, picker);
if (flag) setFlag(false);
const tempDate = new Date(date);
setDate(setDateByType(tempDate, newValue, picker, period));
}
if (e.key >= '0' && e.key <= '9') {
if (picker === '12hours') setPrevIntKey(e.key);
const newValue = calculateNewValue(e.key);
setFlag((prev) => !prev);
const tempDate = new Date(date);
setDate(setDateByType(tempDate, newValue, picker, period));
}
};
return (
<Input
ref={ref}
id={id || picker}
name={name || picker}
className={cn(
'hover:bg-accent caret-primary w-[73px] h-[29px] p-0 text-center rounded-xs bg-transparent transition-all text-sm tabular-nums border-none [&::-webkit-inner-spin-button]:appearance-none',
className,
{
'bg-background': isActive,
},
)}
value={value || calculatedValue}
onChange={(e) => {
e.preventDefault();
onChange?.(e);
}}
type={type}
inputMode="decimal"
onKeyDown={handleKeyDown}
onClick={onClick}
/>
);
},
);
TimeUnitPickerInputInner.displayName = 'TimeUnitPickerInputInner';
const TimeUnitPickerInput = React.forwardRef<
HTMLInputElement,
TimeUnitPickerInputProps
>((props, ref) => {
const { autoCompleteList, isActive } = props;
const [open, setOpen] = React.useState(false);
const listRef = useRef<HTMLDivElement>(null);
const [filterValue, setFilterValue] = React.useState('');
if (isNil(autoCompleteList) || autoCompleteList.length === 0) {
return <TimeUnitPickerInputInner {...props} ref={ref} />;
}
return (
<>
<TimeUnitPickerInputInner
{...props}
onKeyDown={(e) => {
props.onKeyDown?.(e);
if (
e.key === 'ArrowDown' ||
e.key === 'ArrowUp' ||
(e.key === 'Enter' && open)
) {
const event = new KeyboardEvent('keydown', {
key: e.key,
bubbles: true,
cancelable: true,
});
if (listRef.current) {
listRef.current.dispatchEvent(event);
}
event.preventDefault();
}
}}
setDate={(date) => {
props.setDate(date);
const filterValue = getDateByType(date, props.picker);
setFilterValue(
filterValue[0] === '0' ? filterValue.slice(1) : filterValue,
);
}}
ref={ref}
isAutocompleteOpen={open}
onClick={() => {
setFilterValue('');
setOpen(true);
}}
/>
<AutoComplete
className={cn('bg-transparent text-muted-foreground rounded-xs', {
'bg-background': isActive,
'hover:bg-accent': !isActive,
'text-foreground': isActive,
})}
items={autoCompleteList.filter((item) =>
item.label.includes(filterValue),
)}
selectedValue={''}
open={open}
setOpen={(open) => {
setFilterValue('');
setOpen(open);
}}
listRef={listRef}
onSelectedValueChange={(value) => {
const tempDate = new Date(
props.date || new Date(new Date().setHours(0, 0, 0, 0)),
);
props.setDate(
setDateByType(tempDate, value, props.picker, props.period),
);
}}
>
<div className="w-full -mt-2"></div>
</AutoComplete>
</>
);
});
TimeUnitPickerInput.displayName = 'TimeUnitPickerInput';
export { TimeUnitPickerInput };

View File

@@ -0,0 +1,45 @@
'use client';
import * as TogglePrimitive from '@radix-ui/react-toggle';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils';
const toggleVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
{
variants: {
variant: {
default: 'bg-transparent',
outline:
'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-10 px-3',
sm: 'h-9 px-2.5',
lg: 'h-11 px-5',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
));
Toggle.displayName = TogglePrimitive.Root.displayName;
export { Toggle, toggleVariants };

View File

@@ -0,0 +1,61 @@
'use client';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import * as React from 'react';
import { cn } from '@/lib/utils';
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -0,0 +1,45 @@
import Avatar from 'boring-avatars';
import { Tooltip, TooltipTrigger, TooltipContent } from './tooltip';
type UserAvatarProps = {
name: string;
email: string;
size: number;
disableTooltip?: boolean;
};
export function UserAvatar({
name,
email,
size,
disableTooltip = false,
}: UserAvatarProps) {
const tooltip = `${name} (${email})`;
const avatarElement = (
<Avatar
name={email}
size={size}
colors={['#0a0310', '#49007e', '#ff005b', '#ff7d10', '#ffb238']}
variant="bauhaus"
square
className="rounded-full"
/>
);
if (disableTooltip) {
return avatarElement;
}
return (
<Tooltip>
<TooltipTrigger asChild>
<div>
{avatarElement} {disableTooltip}
</div>
</TooltipTrigger>
<TooltipContent side="bottom">{tooltip}</TooltipContent>
</Tooltip>
);
}

View File

@@ -0,0 +1,114 @@
'use client';
import { useVirtualizer } from '@tanstack/react-virtual';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { isNil } from '@activepieces/shared';
import { ScrollArea } from './scroll-area';
interface VirtualizedScrollAreaProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
overscan?: number;
estimateSize: (index: number) => number;
getItemKey?: (index: number) => string | number;
className?: string;
initialScroll?: {
index: number;
clickAfterScroll: boolean;
};
}
export interface VirtualizedScrollAreaRef {
scrollToIndex: (
index: number,
options?: {
align?: 'start' | 'center' | 'end';
behavior?: 'auto' | 'smooth';
},
) => void;
}
const VirtualizedScrollArea = <T,>({
items,
renderItem,
overscan = 5,
estimateSize,
getItemKey,
initialScroll,
className,
...props
}: VirtualizedScrollAreaProps<T>) => {
const scrollAreaViewportRef = React.useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollAreaViewportRef.current,
estimateSize: estimateSize,
overscan,
getItemKey: getItemKey || ((index) => index),
});
const virtualItems = rowVirtualizer.getVirtualItems();
React.useEffect(() => {
if (isNil(initialScroll)) {
return;
}
if (initialScroll.index > -1) {
rowVirtualizer.scrollToIndex(initialScroll.index, {
align: 'start',
behavior: 'auto',
});
if (initialScroll?.clickAfterScroll) {
//need to wait for the scroll to be completed
setTimeout(() => {
const targetElement = scrollAreaViewportRef.current?.querySelector(
`[data-virtual-index="${initialScroll.index}"]`,
);
const renderedElement = targetElement?.children[0];
if (renderedElement instanceof HTMLElement) {
renderedElement.click();
}
}, 100);
}
}
}, [rowVirtualizer]);
return (
<ScrollArea
viewPortRef={scrollAreaViewportRef}
{...props}
className={cn('h-full', className)}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualItems.map((virtualItem) => (
<div
key={virtualItem.key}
data-virtual-index={virtualItem.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{renderItem(items[virtualItem.index], virtualItem.index)}
</div>
))}
</div>
</ScrollArea>
);
};
VirtualizedScrollArea.displayName = 'VirtualizedScrollArea';
export { VirtualizedScrollArea };