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,31 @@
import { cn } from '@/lib/utils';
export const InvalidStepIcon = ({ className }: { className: string }) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={cn(
'stroke-1 dark:stroke-amber-900 stroke-amber-500 ',
className,
)}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle
cx="8.08325"
cy="8.26868"
r="7.5"
className="dark:fill-amber-950 fill-amber-50 "
></circle>
<path
d="M8.08325 3.69368C7.86445 3.69368 7.65461 3.7806 7.49989 3.93531C7.34517 4.09003 7.25825 4.29987 7.25825 4.51868V8.51868C7.25825 8.73748 7.34517 8.94732 7.49989 9.10204C7.65461 9.25676 7.86445 9.34368 8.08325 9.34368C8.30206 9.34368 8.5119 9.25676 8.66662 9.10204C8.82133 8.94732 8.90825 8.73748 8.90825 8.51868V4.51868C8.90825 4.29987 8.82133 4.09003 8.66662 3.93531C8.5119 3.7806 8.30206 3.69368 8.08325 3.69368ZM8.90825 12.0187C8.90825 11.563 8.53889 11.1937 8.08325 11.1937C7.62762 11.1937 7.25825 11.563 7.25825 12.0187C7.25825 12.4743 7.62762 12.8437 8.08325 12.8437C8.53889 12.8437 8.90825 12.4743 8.90825 12.0187Z"
className="dark:fill-amber-600 fill-amber-700"
strokeWidth="0.4"
></path>
</svg>
);
};

View File

@@ -0,0 +1,77 @@
import { AvatarImage } from '@radix-ui/react-avatar';
import { Workflow } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { UserAvatar } from '../ui/user-avatar';
interface ApAvatarProps {
type: 'agent' | 'user' | 'flow';
fullName: string;
userEmail?: string;
pictureUrl?: string;
profileUrl?: string;
size: 'small' | 'medium';
includeName?: boolean;
}
export const ApAvatar = ({
type,
fullName,
userEmail,
pictureUrl,
profileUrl,
includeName = false,
size = 'medium',
}: ApAvatarProps) => {
const renderAvatar = () => {
if (type === 'agent') {
return (
<Avatar className={size === 'small' ? 'w-6 h-6' : 'w-8 h-8'}>
<AvatarImage
src={pictureUrl}
alt={fullName}
className={`${size} rounded-full`}
/>
</Avatar>
);
}
if (type === 'user') {
return (
<UserAvatar
name={fullName}
email={userEmail!}
size={size === 'small' ? 24 : 32}
disableTooltip={true}
/>
);
}
return (
<Avatar className={size === 'small' ? 'w-6 h-6' : 'w-8 h-8'}>
<AvatarFallback
className={`text-xs font-bold border ${
size === 'small' ? 'w-6 h-6' : 'w-8 h-8'
}`}
>
<Workflow className="p-1" />
</AvatarFallback>
</Avatar>
);
};
const content = (
<div className="flex items-center gap-2">
{renderAvatar()}
{includeName && <span className="text-sm">{fullName}</span>}
</div>
);
if (type === 'agent' && profileUrl) {
return <Link to={profileUrl}>{content}</Link>;
}
return content;
};

View File

@@ -0,0 +1,18 @@
import { create } from 'zustand';
type ApErrorDialogParams = {
title: string;
description: React.ReactNode;
error: unknown;
};
interface ApErrorDialogStore {
params: ApErrorDialogParams | null;
openDialog: (params: ApErrorDialogParams) => void;
closeDialog: () => void;
}
export const useApErrorDialogStore = create<ApErrorDialogStore>((set) => ({
params: null,
openDialog: (params) => set({ params }),
closeDialog: () => set({ params: null }),
}));

View File

@@ -0,0 +1,66 @@
import { t } from 'i18next';
import { AlertCircleIcon } from 'lucide-react';
import { CollapsibleJson } from '@/components/custom/collapsible-json';
import { Button } from '@/components/ui/button';
import { isNil } from '@activepieces/shared';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '../../ui/dialog';
import { useApErrorDialogStore } from './ap-error-dialog-store';
const ApErrorDialog = () => {
const { params, closeDialog } = useApErrorDialogStore();
if (isNil(params)) return null;
return (
<Dialog open={!!params} onOpenChange={closeDialog}>
<DialogContent>
<DialogHeader>
<div className="flex flex-col items-center">
<span
className="rounded-full bg-red-100 flex items-center justify-center mb-2 mt-1"
style={{ width: 48, height: 48 }}
>
<AlertCircleIcon className="h-8 w-8 text-red-500" />
</span>
<div className="flex flex-col items-center text-center w-full gap-2">
<DialogTitle className="text-lg font-semibold">
{params?.title}
</DialogTitle>
{params?.description && (
<DialogDescription className="mt-0.5 text-sm text-muted-foreground">
{params.description}
</DialogDescription>
)}
</div>
</div>
</DialogHeader>
<div className="w-full flex flex-col items-stretch mt-2">
<CollapsibleJson
json={params?.error}
label={t('Technical Details')}
defaultOpen={true}
className="w-full text-left"
/>
</div>
<DialogFooter className="mt-2">
<Button variant="outline" onClick={closeDialog}>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
ApErrorDialog.displayName = 'ApErrorDialog';
export { ApErrorDialog };

View File

@@ -0,0 +1,26 @@
import { t } from 'i18next';
import { PanelRightClose, PanelRightOpen } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useSidebar } from '@/components/ui/sidebar-shadcn';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
export const ApSidebarToggle = () => {
const { open, setOpen } = useSidebar();
return (
<Tooltip>
<TooltipTrigger>
<Button variant="ghost" size="icon" onClick={() => setOpen(!open)}>
{open ? <PanelRightOpen /> : <PanelRightClose />}
</Button>
</TooltipTrigger>
<TooltipContent>
{open ? t('Close Sidebar') : t('Open Sidebar')}
</TooltipContent>
</Tooltip>
);
};

View File

@@ -0,0 +1,202 @@
import { DragHandleDots2Icon } from '@radix-ui/react-icons';
import { t } from 'i18next';
import { Plus, TrashIcon } from 'lucide-react';
import { nanoid } from 'nanoid';
import React, { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import { FormControl, FormField, FormItem } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Sortable,
SortableDragHandle,
SortableItem,
} from '@/components/ui/sortable';
import { TextWithIcon } from '@/components/ui/text-with-icon';
import { cn } from '@/lib/utils';
type ArrayInputProps = {
inputName: string;
disabled: boolean;
required?: boolean;
customInputNode?: (
onChange: (value: string) => void,
value: string,
disabled: boolean,
) => React.ReactNode;
thinInputs?: boolean;
};
type ArrayField = {
id: string;
value: string;
};
const ArrayInput = React.memo(
({
inputName,
disabled,
required,
customInputNode,
thinInputs,
}: ArrayInputProps) => {
const form = useFormContext();
const [fields, setFields] = useState<ArrayField[]>(() => {
const formValues = form.getValues(inputName);
if (formValues) {
return formValues.map((value: string) => ({
id: nanoid(),
value,
}));
} else {
return [];
}
});
const updateFormValue = (newFields: ArrayField[]) => {
form.setValue(
inputName,
newFields.map((f) => f.value),
{ shouldValidate: true },
);
};
const append = () => {
const formValues = form.getValues(inputName) || [];
const newFields = [
...formValues.map((value: string) => ({
id: nanoid(),
value,
})),
{ id: nanoid(), value: '' },
];
setFields(newFields);
updateFormValue(newFields);
};
const remove = (index: number) => {
const currentFields: ArrayField[] = form
.getValues(inputName)
.map((value: string) => ({
id: nanoid(),
value,
}));
const newFields = currentFields.filter((_, i) => i !== index);
setFields(newFields);
updateFormValue(newFields);
};
const move = (from: number, to: number) => {
const newFields = [...fields];
const [removed] = newFields.splice(from, 1);
newFields.splice(to, 0, removed);
setFields(newFields);
updateFormValue(newFields);
};
const updateFieldValue = (index: number, newValue: string) => {
const newFields = fields.map((field, i) =>
i === index ? { ...field, value: newValue } : field,
);
setFields(newFields);
updateFormValue(newFields);
};
const showRemoveButton = !required || fields.length > 1;
return (
<>
<div className="flex w-full flex-col gap-4 ">
<Sortable
value={fields}
onMove={({ activeIndex, overIndex }) => {
move(activeIndex, overIndex);
}}
>
{fields.map((field, index) => (
<SortableItem key={field.id} value={field.id} asChild>
<div className="flex items-center gap-3">
<SortableDragHandle
variant="outline"
size="icon"
disabled={disabled}
className={cn('shrink-0 size-8', thinInputs && 'size-7')}
>
<DragHandleDots2Icon
className="size-4"
aria-hidden="true"
/>
</SortableDragHandle>
<FormField
control={form.control}
name={`${inputName}.${index}`}
render={() => (
<FormItem className="grow">
<FormControl>
{customInputNode ? (
customInputNode(
(value) => updateFieldValue(index, value),
field.value as string,
disabled,
)
) : (
<Input
thin={thinInputs}
value={field.value as string}
onChange={(e) =>
updateFieldValue(index, e.target.value)
}
disabled={disabled}
className="grow"
/>
)}
</FormControl>
</FormItem>
)}
/>
{showRemoveButton && (
<Button
type="button"
variant="outline"
size="icon"
disabled={disabled}
className={cn('shrink-0 size-8', thinInputs && 'size-7')}
onClick={() => {
remove(index);
}}
>
<TrashIcon
className="size-4 text-destructive"
aria-hidden="true"
/>
<span className="sr-only">{t('Remove')}</span>
</Button>
)}
</div>
</SortableItem>
))}
</Sortable>
</div>
{!disabled && (
<Button
variant="outline"
size="sm"
className="mt-3"
onClick={() => {
append();
}}
type="button"
>
<TextWithIcon icon={<Plus size={18} />} text={t('Add Item')} />
</Button>
)}
</>
);
},
);
ArrayInput.displayName = 'ArrayInput';
export { ArrayInput };

View File

@@ -0,0 +1,55 @@
import { PermissionNeededTooltip } from '@/components/custom/permission-needed-tooltip';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
type ButtonWithTooltipProps = {
tooltip: string;
onClick: (e?: React.MouseEvent) => void;
variant?:
| 'ghost'
| 'outline'
| 'default'
| 'destructive'
| 'secondary'
| 'link';
icon: React.ReactNode;
className?: string;
disabled?: boolean;
hasPermission?: boolean;
};
export const ButtonWithTooltip = ({
tooltip,
onClick,
variant = 'ghost',
icon,
className = 'h-7 w-7',
disabled = false,
hasPermission = true,
}: ButtonWithTooltipProps) => (
<PermissionNeededTooltip hasPermission={hasPermission}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={variant}
size="icon"
className={className}
onClick={onClick}
disabled={disabled || !hasPermission}
>
{icon}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</PermissionNeededTooltip>
);

View File

@@ -0,0 +1,113 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { PackageOpen } from 'lucide-react';
import React, { forwardRef } from 'react';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import { Skeleton } from '../ui/skeleton';
const CardList = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { listClassName?: string }
>(({ children, className, listClassName, ...props }, ref) => (
<ScrollArea
className={`h-full overflow-auto ${className}`}
viewPortClassName="[&>div]:h-full"
>
<div
ref={ref}
className={cn('flex flex-col h-full w-full', listClassName)}
{...props}
>
{children}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
));
CardList.displayName = 'CardList';
export { CardList };
const cardItemListVariants = cva('flex items-center gap-4 w-full py-4 px-2 ', {
variants: {
interactive: {
true: 'cursor-pointer transition-all hover:bg-accent hover:text-accent-foreground',
false: 'cursor-default text-accent-foreground/50 font-semibold',
},
selected: {
true: 'bg-accent text-accent-foreground',
false: '',
},
},
defaultVariants: {
interactive: true,
selected: false,
},
});
type CardListItemProps = React.HTMLAttributes<HTMLDivElement> &
VariantProps<typeof cardItemListVariants> & {
children: React.ReactNode;
};
const CardListItem = React.forwardRef<HTMLDivElement, CardListItemProps>(
({ children, onClick, className, interactive, selected, ...props }, ref) => {
return (
<div
onClick={onClick}
ref={ref}
className={cn(
cardItemListVariants({ interactive, selected }),
className,
)}
{...props}
>
{children}
</div>
);
},
);
CardListItem.displayName = 'CardListItem';
export { CardListItem };
type CardListItemSkeletonProps = {
numberOfCards?: number;
withCircle?: boolean;
};
const CardListItemSkeleton: React.FC<CardListItemSkeletonProps> = React.memo(
({ numberOfCards = 3, withCircle = true }) => {
return (
<>
{[...Array(numberOfCards)].map((_, index) => (
<div key={index} className="flex items-center gap-4 w-full py-4 px-5">
{withCircle && <Skeleton className="h-8 w-8 rounded-full" />}
<div className="space-y-2">
<Skeleton className="h-4 w-[250px]" />
<Skeleton className="h-4 w-[200px]" />
</div>
</div>
))}
</>
);
},
);
CardListItemSkeleton.displayName = 'CardListItemSkeleton';
export { CardListItemSkeleton };
type CardListEmptyProps = React.HTMLAttributes<HTMLDivElement> & {
message: string;
};
const CardListEmpty = React.memo(({ message }: CardListEmptyProps) => {
return (
<div className="flex h-full w-full items-center justify-center gap-3 flex-col text-muted-foreground">
<PackageOpen className="w-10 h-10" />
<div className="text-center tracking-tight">{message}</div>
</div>
);
});
CardListEmpty.displayName = 'CardListEmpty';
export { CardListEmpty };

View File

@@ -0,0 +1,65 @@
interface Props {
value: number;
size?: number;
strokeWidth?: number;
label?: string;
}
export const CircularIcon: React.FC<Props> = ({
value,
size = 50,
strokeWidth = 3.5,
label,
}) => {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (value / 100) * circumference;
return (
<div className="flex items-center gap-3">
{/* Progress Circle */}
<svg width={size} height={size} className="inline-block">
{/* Background Circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
strokeWidth={strokeWidth}
stroke="currentColor"
className="text-gray-200 dark:text-gray-700"
fill="transparent"
/>
{/* Progress Circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
strokeWidth={strokeWidth}
stroke="currentColor"
className="text-primary"
fill="transparent"
strokeDasharray={circumference}
strokeDashoffset={offset}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
{/* Percentage Label */}
<text
x="50%"
y="50%"
dominantBaseline="middle"
textAnchor="middle"
fontSize={size * 0.225}
fontWeight="bold"
className="fill-current text-gray-700 dark:text-gray-200"
>
{value.toFixed(1)}%
</text>
</svg>
{/* Label */}
{label && (
<div className="text-sm text-gray-700 dark:text-gray-400">{label}</div>
)}
</div>
);
};

View File

@@ -0,0 +1,49 @@
import { PolarGrid, RadialBar, RadialBarChart } from 'recharts';
import { ChartContainer } from '../ui/chart';
const ProgressCircularComponent: React.FC<{
data: {
plan: number;
usage: number;
};
size?: 'big' | 'small';
}> = ({ data, size = 'big' }) => {
const sizeClass = size === 'big' ? 'size-[40px]' : 'size-[25px]';
return (
<div className={`overflow-hidden ${sizeClass}`}>
<ChartContainer
config={{}}
className={`mx-auto aspect-square max-h-[250px] min-h-[180px] ${sizeClass}`}
>
<RadialBarChart
data={[
{
name: 'plan',
progress: data.usage,
fill: 'hsl(var(--primary))',
},
]}
startAngle={0}
endAngle={(data.usage / data.plan) * 360}
innerRadius={80}
outerRadius={110}
style={{
height: size === 'big' ? 40 : 25,
}}
>
<PolarGrid
gridType="circle"
radialLines={false}
stroke="none"
className="first:fill-muted last:fill-background"
polarRadius={[86, 74]}
/>
<RadialBar dataKey="progress" background cornerRadius={10} />
</RadialBarChart>
</ChartContainer>
</div>
);
};
export { ProgressCircularComponent };

View File

@@ -0,0 +1,82 @@
import { TooltipContentProps } from '@radix-ui/react-tooltip';
import { useMutation } from '@tanstack/react-query';
import { t } from 'i18next';
import { Check, Copy } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { Button, ButtonProps } from '@/components/ui/button';
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from '@/components/ui/tooltip';
interface CopyButtonProps extends ButtonProps {
textToCopy: string;
tooltipSide?: TooltipContentProps['side'];
withoutTooltip?: boolean;
}
export const CopyButton = ({
textToCopy,
className,
tooltipSide,
withoutTooltip = false,
...props
}: CopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const { mutate: copyToClipboard } = useMutation({
mutationFn: async () => {
await navigator.clipboard.writeText(textToCopy);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 1500);
},
onError: () => {
toast.error(t('Failed to copy to clipboard'), {
duration: 3000,
});
},
});
if (withoutTooltip) {
return (
<Button
variant="outline"
size="icon"
type="button"
className={className}
onClick={() => copyToClipboard()}
{...props}
>
{isCopied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
);
}
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
type="button"
className={className}
onClick={() => copyToClipboard()}
{...props}
>
{isCopied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side={tooltipSide}>{t('Copy')}</TooltipContent>
</Tooltip>
);
};

View File

@@ -0,0 +1,32 @@
import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/tooltip';
import { CopyButton } from './copy-button';
const CopyTextTooltip = ({
text,
title,
children,
}: {
text: string;
title: string;
children: React.ReactNode;
}) => {
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent>
<div className="flex text-xs gap-2 items-center">
{title}: {text || '-'}{' '}
<CopyButton
withoutTooltip={true}
variant="ghost"
textToCopy={text || ''}
></CopyButton>
</div>
</TooltipContent>
</Tooltip>
);
};
CopyTextTooltip.displayName = 'CopyTextTooltip';
export { CopyTextTooltip };

View File

@@ -0,0 +1,54 @@
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { DownloadButton } from '../download-button';
import { CopyButton } from './copy-button';
type CopyToClipboardInputProps = {
textToCopy: string;
useInput: boolean;
fileName?: string;
};
const noBorderInputClass = `border-none w-full rfocus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0`;
const CopyToClipboardInput = ({
textToCopy,
fileName,
useInput,
}: CopyToClipboardInputProps) => {
return (
<div className="flex gap-2 items-center bg-background border border-solid text-sm rounded block w-full select-none pr-3">
{useInput ? (
<Input value={textToCopy} className={noBorderInputClass} readOnly />
) : (
<Textarea
value={textToCopy}
rows={6}
className={noBorderInputClass}
readOnly
/>
)}
<div
className={cn('flex gap-1', {
'flex-col': !useInput,
})}
>
<CopyButton textToCopy={textToCopy} variant="ghost" />
{fileName && (
<DownloadButton
textToDownload={textToCopy}
fileName={fileName}
variant="ghost"
tooltipSide="bottom"
/>
)}
</div>
</div>
);
};
CopyToClipboardInput.displayName = 'CopyToClipboardInput';
export { CopyToClipboardInput };

View File

@@ -0,0 +1,58 @@
import { ChevronDown, ChevronRight } from 'lucide-react';
import { useState } from 'react';
import { CopyButton } from '@/components/custom/clipboard/copy-button';
export function CollapsibleJson({
json,
label,
description,
defaultOpen = false,
className = '',
}: CollapsibleJsonProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const toggleVisibility = () => setIsOpen(!isOpen);
const jsonString =
typeof json === 'string' ? json : JSON.stringify(json, null, 2);
return (
<div className={`flex flex-col gap-2 ${className}`}>
<button
onClick={toggleVisibility}
className="flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{isOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
{label}
</button>
{isOpen && (
<div className="flex flex-col gap-2 min-w-0">
<div className="relative min-w-0">
<pre className="bg-muted/50 whitespace-pre-wrap break-all rounded-md px-4 py-4 text-xs overflow-x-auto max-w-full">
<code>{jsonString}</code>
</pre>
<div className="absolute top-2 right-2">
<CopyButton textToCopy={jsonString} />
</div>
</div>
{description && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
</div>
)}
</div>
);
}
type CollapsibleJsonProps = {
json: unknown;
label: string;
description?: string;
defaultOpen?: boolean;
className?: string;
};

View File

@@ -0,0 +1,52 @@
import { TooltipContentProps } from '@radix-ui/react-tooltip';
import { t } from 'i18next';
import { Download } from 'lucide-react';
import { Button, ButtonProps } from '@/components/ui/button';
import { Tooltip, TooltipTrigger, TooltipContent } from '../ui/tooltip';
interface DownloadButtonProps extends ButtonProps {
fileName: string;
textToDownload: string;
tooltipSide?: TooltipContentProps['side'];
}
export const DownloadButton = ({
fileName,
className,
textToDownload,
tooltipSide,
...props
}: DownloadButtonProps) => {
const downloadFile = () => {
const blob = new Blob([textToDownload], {
type: 'text/plain',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${fileName}.txt`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className={className}
onClick={() => downloadFile()}
{...props}
>
<Download className="h-4 w-4"></Download>
</Button>
</TooltipTrigger>
<TooltipContent side={tooltipSide}>{t('Download')}</TooltipContent>
</Tooltip>
);
};

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { cn } from '@/lib/utils';
const inputClass =
'grow flex h-10 w-full rounded-sm border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-within:outline-hidden focus-within:ring-1 focus-within:ring-ring focus-within:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 box-border';
const InputWithIcon = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement> & {
icon: React.ReactNode;
}
>(({ className, ...props }, ref) => (
<div className={cn(inputClass, className, 'items-center gap-2')}>
{props.icon}
<input
ref={ref}
className={cn(
'flex h-full w-full rounded-md bg-transparent text-sm outline-hidden placeholder:text-muted-foreground',
{ 'cursor-not-allowed opacity-50': props.disabled },
)}
{...props}
/>
</div>
));
InputWithIcon.displayName = 'InputWithIcon';
export { InputWithIcon };

View File

@@ -0,0 +1,90 @@
import { json } from '@codemirror/lang-json';
import { githubDark, githubLight } from '@uiw/codemirror-theme-github';
import CodeMirror, {
EditorState,
EditorView,
ReactCodeMirrorRef,
} from '@uiw/react-codemirror';
import React, { RefObject, useRef, useState } from 'react';
import { ControllerRenderProps } from 'react-hook-form';
import { useTheme } from '@/components/theme-provider';
import { cn } from '@/lib/utils';
const styleTheme = EditorView.baseTheme({
'&.cm-editor.cm-focused': {
outline: 'none',
},
});
const convertToString = (value: unknown): string => {
if (typeof value === 'string') {
return value;
}
return JSON.stringify(value, null, 2);
};
const tryParseJson = (value: unknown): unknown => {
if (typeof value !== 'string') {
return value;
}
try {
return JSON.parse(value);
} catch (e) {
return value;
}
};
type JsonEditorProps = {
field: ControllerRenderProps<Record<string, any>, string>;
readonly: boolean;
onFocus?: (ref: RefObject<ReactCodeMirrorRef>) => void;
className?: string;
};
const JsonEditor = React.memo(
({ field, readonly, onFocus, className }: JsonEditorProps) => {
const [value, setValue] = useState(convertToString(field.value));
const { theme } = useTheme();
const editorTheme = theme === 'dark' ? githubDark : githubLight;
const extensions = [
styleTheme,
EditorState.readOnly.of(readonly),
EditorView.editable.of(!readonly),
EditorView.lineWrapping,
json(),
];
const ref = useRef<ReactCodeMirrorRef>(null);
return (
<div className="flex flex-col gap-2 border rounded py-2 px-2">
<CodeMirror
ref={ref}
value={value}
className={cn('border-none', className)}
height="250px"
width="100%"
maxWidth="100%"
basicSetup={{
foldGutter: false,
lineNumbers: true,
searchKeymap: false,
lintKeymap: true,
autocompletion: true,
}}
lang="json"
onChange={(value) => {
setValue(value);
field.onChange(tryParseJson(value));
}}
theme={editorTheme}
readOnly={readonly}
onFocus={() => onFocus?.(ref)}
extensions={extensions}
/>
</div>
);
},
);
JsonEditor.displayName = 'JsonEditor';
export { JsonEditor };

View File

@@ -0,0 +1,209 @@
import { useMutation } from '@tanstack/react-query';
import { t } from 'i18next';
import { Check, Copy, Info, AlertTriangle, Lightbulb } from 'lucide-react';
import React, { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import breaks from 'remark-breaks';
import gfm from 'remark-gfm';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { MarkdownVariant } from '@activepieces/shared';
import { Alert, AlertDescription } from '../ui/alert';
import { Button } from '../ui/button';
function applyVariables(markdown: string, variables: Record<string, string>) {
if (typeof markdown !== 'string') {
return '';
}
let result = markdown.split('<br>').join('\n');
result = result.replace(/\{\{(.*?)\}\}/g, (_, variableName) => {
return variables[variableName] ?? '';
});
return result;
}
type MarkdownProps = {
markdown: string | undefined;
variables?: Record<string, string>;
variant?: MarkdownVariant;
className?: string;
loading?: string;
};
const Container = ({
variant,
children,
}: {
variant?: MarkdownVariant;
children: React.ReactNode;
}) => {
return (
<Alert
className={cn('rounded-md border', {
'dark:bg-amber-950 bg-amber-50 border-none dark:text-amber-600 text-amber-700':
variant === MarkdownVariant.WARNING,
'bg-success-100 text-success-300 border-none':
variant === MarkdownVariant.TIP,
'p-0 bg-transparent border-none':
variant === MarkdownVariant.BORDERLESS,
})}
>
{variant !== MarkdownVariant.BORDERLESS && (
<>
{(variant === MarkdownVariant.INFO || variant === undefined) && (
<Info className="w-4 h-4 mt-1" />
)}
{variant === MarkdownVariant.WARNING && (
<AlertTriangle className="w-4 h-4 mt-1 stroke-amber-700" />
)}
{variant === MarkdownVariant.TIP && (
<Lightbulb className="w-4 h-4 mt-1" />
)}
</>
)}
<AlertDescription className="grow w-full">{children}</AlertDescription>
</Alert>
);
};
const ApMarkdown = React.memo(
({ markdown, variables, variant, className, loading }: MarkdownProps) => {
const [copiedText, setCopiedText] = useState<string | null>(null);
const { mutate: copyToClipboard } = useMutation({
mutationFn: async (text: string) => {
await navigator.clipboard.writeText(text);
setCopiedText(text);
await new Promise((resolve) => setTimeout(resolve, 1000));
setCopiedText(null);
},
onError: () => {
toast.error(t('Failed to copy to clipboard'), {
duration: 3000,
});
},
});
if (loading && loading.length > 0) {
return (
<Container variant={variant}>
<div className="flex items-center gap-2">{loading}</div>
</Container>
);
}
if (!markdown) {
return null;
}
const markdownProcessed = applyVariables(markdown, variables ?? {});
return (
<Container variant={variant}>
<ReactMarkdown
className={cn('grow w-full ', className)}
remarkPlugins={[gfm, breaks]}
components={{
code(props) {
const isLanguageText = props.className?.includes('language-text');
if (!isLanguageText) {
return <code {...props} className="text-wrap" />;
}
const codeContent = String(props.children).trim();
const isCopying = codeContent === copiedText;
return (
<div className="relative w-full items-center flex bg-background border border-solid text-sm rounded block w-full gap-1 p-1.5">
<input
type="text"
className="grow bg-background"
value={codeContent}
disabled
/>
<Button
variant="ghost"
className="bg-background rounded p-2 inline-flex items-center justify-center h-8"
onClick={() => copyToClipboard(codeContent)}
>
{isCopying ? (
<Check className="w-3 h-3" />
) : (
<Copy className="w-3 h-3" />
)}
</Button>
</div>
);
},
h1: ({ node, ...props }) => (
<h1
className="scroll-m-20 text-xl font-extrabold tracking-tight lg:text-3xl"
{...props}
/>
),
h2: ({ node, ...props }) => (
<h2
className="scroll-m-20 text-lg text-xl font-semibold tracking-tight first:mt-0"
{...props}
/>
),
h3: ({ node, ...props }) => (
<h3
className="scroll-m-20 text-lg font-semibold tracking-tight"
{...props}
/>
),
p: ({ node, ...props }) => (
<p className="leading-7 not-first:mt-2 w-full" {...props} />
),
ul: ({ node, ...props }) => (
<ul className="mt-4 ml-6 list-disc [&>li]:mt-2" {...props} />
),
ol: ({ node, ...props }) => (
<ol className="mt-4 ml-6 list-decimal [&>li]:mt-2" {...props} />
),
li: ({ node, ...props }) => <li {...props} />,
a: ({ node, ...props }) => (
<a
className="font-medium text-primary underline underline-offset-4"
target="_blank"
rel="noreferrer noopener"
{...props}
/>
),
blockquote: ({ node, ...props }) => (
<blockquote
className="mt-4 first:mt-0 border-l-2 pl-6 italic"
{...props}
/>
),
hr: ({ node, ...props }) => (
<hr className="my-4 border-t border-border/50" {...props} />
),
img: ({ node, ...props }) => <img className="my-8" {...props} />,
b: ({ node, ...props }) => <b {...props} />,
em: ({ node, ...props }) => <em {...props} />,
table: ({ node, ...props }) => (
<table className="w-full my-4 border-collapse" {...props} />
),
thead: ({ node, ...props }) => (
<thead className="bg-muted" {...props} />
),
tr: ({ node, ...props }) => (
<tr className="border-b border-border" {...props} />
),
th: ({ node, ...props }) => (
<th className="text-left p-2 font-medium" {...props} />
),
td: ({ node, ...props }) => <td className="p-2" {...props} />,
}}
>
{markdownProcessed.trim()}
</ReactMarkdown>
</Container>
);
},
);
ApMarkdown.displayName = 'ApMarkdown';
export { ApMarkdown };

View File

@@ -0,0 +1,160 @@
import deepEqual from 'deep-equal';
import { t } from 'i18next';
import { useState } from 'react';
import {
MultiSelect,
MultiSelectContent,
MultiSelectItem,
MultiSelectList,
MultiSelectSearch,
MultiSelectTrigger,
MultiSelectValue,
} from '@/components/custom/multi-select';
import { CommandEmpty } from '@/components/ui/command';
type MultiSelectPiecePropertyProps = {
placeholder: string;
options: {
value: unknown;
label: string;
}[];
onChange: (value: unknown[] | null) => void;
initialValues?: unknown[];
disabled?: boolean;
showDeselect?: boolean;
showRefresh?: boolean;
loading?: boolean;
onRefresh?: () => void;
refreshOnSearch?: (term: string) => void;
/**Use to show the selected option when search doesn't return the selected option */
cachedOptions?: {
value: unknown;
label: string;
}[];
};
const MultiSelectPieceProperty = ({
placeholder,
options,
onChange,
disabled,
initialValues,
showDeselect,
showRefresh,
onRefresh,
loading,
refreshOnSearch,
cachedOptions = [],
}: MultiSelectPiecePropertyProps) => {
const [searchTerm, setSearchTerm] = useState('');
const filteredOptions = options
.map((option, index) => ({
...option,
originalIndex: index,
}))
.filter((option) => {
if (refreshOnSearch) {
return true;
}
return option.label?.toLowerCase()?.includes(searchTerm?.toLowerCase());
});
const selectedIndicies = initialValues
? initialValues
.map((value) =>
[...cachedOptions, ...options].findIndex((option) =>
deepEqual(option.value, value),
),
)
.filter((index) => index > -1)
.map((index) => String(index))
: [];
const sendChanges = (indicides: string[]) => {
const newSelectedIndicies = indicides.filter(
(index) => index !== undefined,
);
if (newSelectedIndicies.length === 0) {
onChange([]);
} else {
onChange(
newSelectedIndicies.map((index) => options[Number(index)].value),
);
}
};
return (
<MultiSelect
modal={true}
value={selectedIndicies}
onValueChange={sendChanges}
disabled={disabled}
onSearch={(searchTerm) => {
setSearchTerm(searchTerm ?? '');
if (refreshOnSearch) {
refreshOnSearch(searchTerm ?? '');
}
}}
onOpenChange={(open) => {
if (!open) {
setSearchTerm('');
if (refreshOnSearch && searchTerm.length > 0) {
refreshOnSearch('');
}
}
}}
>
<MultiSelectTrigger
showDeselect={showDeselect && !disabled}
onDeselect={() => onChange([])}
showRefresh={showRefresh && !disabled}
onRefresh={onRefresh}
loading={loading}
>
{selectedIndicies.length < 10 ? (
<MultiSelectValue placeholder={placeholder} />
) : (
t('{number} items selected', { number: selectedIndicies.length })
)}
</MultiSelectTrigger>
<MultiSelectContent>
<MultiSelectSearch placeholder={placeholder} />
<MultiSelectList>
{!loading && (
<>
<div
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onChange(filteredOptions.map((opt) => opt.value));
}}
>
{filteredOptions.length > 1 && (
<MultiSelectItem>{t('Select All')}</MultiSelectItem>
)}
</div>
{filteredOptions.map((opt) => (
<MultiSelectItem
key={opt.originalIndex}
value={String(opt.originalIndex)}
>
{opt.label}
</MultiSelectItem>
))}
{filteredOptions.length === 0 && (
<CommandEmpty>{t('No results found.')}</CommandEmpty>
)}
</>
)}
{loading && (
<MultiSelectItem disabled>{t('Loading...')}</MultiSelectItem>
)}
</MultiSelectList>
</MultiSelectContent>
</MultiSelect>
);
};
MultiSelectPieceProperty.displayName = 'MultiSelectPieceProperty';
export { MultiSelectPieceProperty };

View File

@@ -0,0 +1,614 @@
'use client';
// Used form here https://github.com/shadcn-ui/ui/pull/2773/files
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { Primitive } from '@radix-ui/react-primitive';
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import { t } from 'i18next'; // Use t function from react-i18next
import { Check, ChevronsUpDown, RefreshCcw, X } from 'lucide-react';
import React, { ComponentPropsWithoutRef } from 'react';
import { createPortal } from 'react-dom';
import { SelectUtilButton } from '@/components/custom/select-util-button';
import { cn } from '@/lib/utils';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '../ui/command';
import { ScrollArea } from '../ui/scroll-area';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '../ui/tooltip';
export interface MultiSelectOptionItem {
value: unknown;
label?: React.ReactNode;
}
interface MultiSelectContextValue {
value: string[];
open: boolean;
onSelect(value: string, item: MultiSelectOptionItem): void;
onDeselect(value: string, item: MultiSelectOptionItem): void;
onSearch?(keyword: string | undefined): void;
filter?: boolean | ((keyword: string, current: string) => boolean);
disabled?: boolean;
maxCount?: number;
itemCache: Map<string, MultiSelectOptionItem>;
}
const MultiSelectContext = React.createContext<
MultiSelectContextValue | undefined
>(undefined);
const useMultiSelect = () => {
const context = React.useContext(MultiSelectContext);
if (!context) {
throw new Error(
t('useMultiSelect must be used within MultiSelectProvider'),
);
}
return context;
};
type MultiSelectProps = React.ComponentPropsWithoutRef<
typeof PopoverPrimitive.Root
> & {
value?: string[];
onValueChange?(value: string[], items: MultiSelectOptionItem[]): void;
onSelect?(value: string, item: MultiSelectOptionItem): void;
onDeselect?(value: string, item: MultiSelectOptionItem): void;
defaultValue?: string[];
onSearch?(keyword: string | undefined): void;
filter?: boolean | ((keyword: string, current: string) => boolean);
disabled?: boolean;
maxCount?: number;
};
const MultiSelect: React.FC<MultiSelectProps> = ({
value: valueProp,
onValueChange: onValueChangeProp,
onDeselect: onDeselectProp,
onSelect: onSelectProp,
defaultValue,
open: openProp,
onOpenChange,
defaultOpen,
onSearch,
filter,
disabled,
maxCount,
...popoverProps
}) => {
const itemCache = React.useRef(
new Map<string, MultiSelectOptionItem>(),
).current;
const handleValueChange = React.useCallback(
(state: string[]) => {
if (onValueChangeProp) {
const items = state.map((value) => itemCache.get(value)!);
onValueChangeProp(state, items);
}
},
[onValueChangeProp],
);
const [value, setValue] = useControllableState({
prop: valueProp,
defaultProp: defaultValue,
onChange: handleValueChange,
});
const [open, setOpen] = useControllableState({
prop: openProp,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
const handleSelect = React.useCallback(
(value: string, item: MultiSelectOptionItem) => {
setValue((prev) => {
if (prev?.includes(value)) {
return prev;
}
onSelectProp?.(value, item);
return prev ? [...prev, value] : [value];
});
},
[onSelectProp, setValue],
);
const handleDeselect = React.useCallback(
(value: string, item: MultiSelectOptionItem) => {
setValue((prev) => {
if (!prev || !prev.includes(value)) {
return prev;
}
onDeselectProp?.(value, item);
return prev.filter((v) => v !== value);
});
},
[onDeselectProp, setValue],
);
const contextValue = React.useMemo(() => {
return {
value: value || [],
open: open || false,
onSearch,
filter,
disabled,
maxCount,
onSelect: handleSelect,
onDeselect: handleDeselect,
itemCache,
};
}, [
value,
open,
onSearch,
filter,
disabled,
maxCount,
handleSelect,
handleDeselect,
itemCache,
]);
return (
<MultiSelectContext.Provider value={contextValue}>
<PopoverPrimitive.Root
{...popoverProps}
open={open}
onOpenChange={setOpen}
/>
</MultiSelectContext.Provider>
);
};
MultiSelect.displayName = 'MultiSelect';
type MultiSelectTriggerElement = React.ElementRef<typeof Primitive.button>;
type MultiSelectTriggerProps = ComponentPropsWithoutRef<
typeof Primitive.button
> & {
showDeselect?: boolean;
onDeselect?: () => void;
showRefresh?: boolean;
onRefresh?: () => void;
loading?: boolean;
};
const PreventClick = (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
e.stopPropagation();
};
const MultiSelectTrigger = React.forwardRef<
MultiSelectTriggerElement,
MultiSelectTriggerProps
>(
(
{ className, children, showDeselect, onDeselect, loading, ...props },
forwardedRef,
) => {
const { disabled } = useMultiSelect();
return (
<PopoverPrimitive.Trigger ref={forwardedRef as any} asChild>
<Button
variant="outline"
aria-disabled={disabled}
disabled={disabled}
role="combobox"
loading={loading}
className={cn(
'flex min-h-10 h-auto w-full items-center justify-between cursor-pointer gap-2 whitespace-nowrap rounded-sm border border-input bg-transparent px-4 py-1 text-sm ring-offset-background focus:outline-hidden focus:ring-1 focus:ring-ring [&>span]:line-clamp-1',
{
'cursor-not-allowed opacity-80': disabled,
'cursor-pointer': !disabled,
},
className,
)}
onClick={disabled ? PreventClick : props.onClick}
onTouchStart={disabled ? PreventClick : props.onTouchStart}
>
{children}
<div className="flex gap-2 items-center">
{showDeselect && (
<SelectUtilButton
tooltipText={t('Unset')}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDeselect?.();
}}
Icon={X}
></SelectUtilButton>
)}
{props.showRefresh && (
<SelectUtilButton
tooltipText={t('Refresh')}
onClick={props.onRefresh}
Icon={RefreshCcw}
></SelectUtilButton>
)}
<ChevronsUpDown
aria-hidden
className="h-4 w-4 opacity-50 shrink-0"
/>
</div>
</Button>
</PopoverPrimitive.Trigger>
);
},
);
MultiSelectTrigger.displayName = 'MultiSelectTrigger';
interface MultiSelectValueProps
extends ComponentPropsWithoutRef<typeof Primitive.div> {
placeholder?: string;
maxDisplay?: number;
maxItemLength?: number;
}
const MultiSelectValue = React.forwardRef<
React.ElementRef<typeof Primitive.div>,
MultiSelectValueProps
>(
(
{ className, placeholder, maxDisplay, maxItemLength, ...props },
forwardRef,
) => {
const { value, itemCache, onDeselect, disabled } = useMultiSelect();
const [firstRendered, setFirstRendered] = React.useState(false);
const remainingPiecesCount =
maxDisplay && value.length > maxDisplay ? value.length - maxDisplay : 0;
const renderItems = remainingPiecesCount
? value.slice(0, maxDisplay)
: value;
React.useLayoutEffect(() => {
setFirstRendered(true);
}, []);
if (!value.length || !firstRendered) {
return (
<span className="pointer-events-none text-muted-foreground opacity-80">
{placeholder}
</span>
);
}
return (
<TooltipProvider delayDuration={300}>
<div
className={cn(
'flex flex-1 overflow-x-hidden flex-wrap items-center gap-1.5',
className,
)}
{...props}
ref={forwardRef}
>
{renderItems.map((value) => {
const item = itemCache.get(value);
const content = item?.label || value;
const child =
maxItemLength &&
typeof content === 'string' &&
content.length > maxItemLength
? `${content.slice(0, maxItemLength)}...`
: content;
const el = (
<Badge
variant="outline"
key={value}
className={cn('pr-1.5 group/multi-select-badge rounded-full', {
'cursor-pointer': !disabled,
'cursor-not-allowed opacity-80': disabled,
})}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDeselect(value, item!);
}}
>
<span>{child}</span>
{!disabled && (
<X className="h-3 w-3 ml-1 text-muted-foreground group-hover/multi-select-badge:text-foreground" />
)}
</Badge>
);
if (child !== content) {
return (
<Tooltip key={value}>
<TooltipTrigger className="inline-flex">{el}</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="z-51">
{content}
</TooltipContent>
</Tooltip>
);
}
return el;
})}
{remainingPiecesCount ? (
<span className="text-muted-foreground text-xs leading-4 py-.5">
{t('+{remainingPiecesCount} more', {
remainingPiecesCount: remainingPiecesCount,
})}
</span>
) : null}
</div>
</TooltipProvider>
);
},
);
MultiSelectValue.displayName = 'MultiSelectValue';
const MultiSelectSearch = React.forwardRef<
React.ElementRef<typeof CommandInput>,
ComponentPropsWithoutRef<typeof CommandInput>
>((props, ref) => {
const { onSearch } = useMultiSelect();
return <CommandInput ref={ref} {...props} onValueChange={onSearch} />;
});
MultiSelectSearch.displayName = 'MultiSelectSearch';
const MultiSelectList = React.forwardRef<
React.ElementRef<typeof CommandList>,
ComponentPropsWithoutRef<typeof CommandList>
>(({ className, ...props }, ref) => {
return (
<CommandList ref={ref} className={cn('py-1 px-0 ', className)} {...props}>
<ScrollArea viewPortClassName="max-h-[200px]">
{props.children}
</ScrollArea>
</CommandList>
);
});
MultiSelectList.displayName = 'MultiSelectList';
type MultiSelectContentProps = ComponentPropsWithoutRef<
typeof PopoverPrimitive.Content
>;
const MultiSelectContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
MultiSelectContentProps
>(({ className, children, ...props }, ref) => {
const context = useMultiSelect();
const fragmentRef = React.useRef<DocumentFragment>();
if (!fragmentRef.current && typeof window !== 'undefined') {
fragmentRef.current = document.createDocumentFragment();
}
if (!context.open) {
return fragmentRef.current
? createPortal(<Command>{children}</Command>, fragmentRef.current)
: null;
}
return (
<PopoverPrimitive.Portal forceMount>
<PopoverPrimitive.Content
ref={ref}
align="start"
sideOffset={4}
collisionPadding={10}
className={cn(
'z-50 w-full rounded-md border bg-background p-0 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',
)}
style={
{
'--radix-select-content-transform-origin':
'var(--radix-popper-transform-origin)',
'--radix-select-content-available-width':
'var(--radix-popper-available-width)',
'--radix-select-content-available-height':
'var(--radix-popper-available-height)',
'--radix-select-trigger-width': 'var(--radix-popper-anchor-width)',
'--radix-select-trigger-height':
'var(--radix-popper-anchor-height)',
} as any
}
{...props}
>
<Command
className={cn(
'px-1 max-h-96 w-full min-w-(--radix-select-trigger-width)',
className,
)}
shouldFilter={!context.onSearch}
>
{children}
</Command>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
);
});
MultiSelectContent.displayName = 'MultiSelectContent';
type MultiSelectItemProps = ComponentPropsWithoutRef<typeof CommandItem> &
Partial<MultiSelectOptionItem> & {
onSelect?: (value: string, item: MultiSelectOptionItem) => void;
onDeselect?: (value: string, item: MultiSelectOptionItem) => void;
};
const MultiSelectItem = React.forwardRef<
React.ElementRef<typeof CommandItem>,
MultiSelectItemProps
>(
(
{
value,
onSelect: onSelectProp,
onDeselect: onDeselectProp,
children,
label,
disabled: disabledProp,
className,
...props
},
forwardedRef,
) => {
const {
value: contextValue,
maxCount,
onSelect,
onDeselect,
itemCache,
} = useMultiSelect();
const item = React.useMemo(() => {
return value
? {
value,
label:
label || (typeof children === 'string' ? children : undefined),
}
: undefined;
}, [value, label, children]);
const selected = Boolean(value && contextValue.includes(value));
React.useEffect(() => {
if (value) {
itemCache.set(value, item!);
}
}, [selected, value, item]);
const disabled = Boolean(
disabledProp ||
(!selected && maxCount && contextValue.length >= maxCount),
);
const handleClick = () => {
if (selected) {
onDeselectProp?.(value!, item!);
onDeselect(value!, item!);
} else {
itemCache.set(value!, item!);
onSelectProp?.(value!, item!);
onSelect(value!, item!);
}
};
return (
<CommandItem
{...props}
value={value}
className={cn(
'cursor-pointer',
disabled && 'text-muted-foreground cursor-not-allowed',
className,
)}
disabled={disabled}
onSelect={!disabled && value ? handleClick : undefined}
ref={forwardedRef}
>
<span className="mr-2 whitespace-nowrap overflow-hidden text-ellipsis">
{children || label || value}
</span>
{selected ? <Check className="h-4 w-4 ml-auto shrink-0" /> : null}
</CommandItem>
);
},
);
MultiSelectItem.displayName = 'MultiSelectItem';
const MultiSelectGroup = React.forwardRef<
React.ElementRef<typeof CommandGroup>,
ComponentPropsWithoutRef<typeof CommandGroup>
>((props, forwardRef) => {
return <CommandGroup {...props} ref={forwardRef} />;
});
MultiSelectGroup.displayName = 'MultiSelectGroup';
const MultiSelectSeparator = React.forwardRef<
React.ElementRef<typeof CommandSeparator>,
ComponentPropsWithoutRef<typeof CommandSeparator>
>((props, forwardRef) => {
return <CommandSeparator {...props} ref={forwardRef} />;
});
MultiSelectSeparator.displayName = 'MultiSelectSeparator';
const MultiSelectEmpty = React.forwardRef<
React.ElementRef<typeof CommandEmpty>,
ComponentPropsWithoutRef<typeof CommandEmpty>
>(({ children = 'No Content', ...props }, forwardRef) => {
return (
<CommandEmpty {...props} ref={forwardRef}>
{children}
</CommandEmpty>
);
});
MultiSelectEmpty.displayName = 'MultiSelectEmpty';
export interface MultiSelectOptionSeparator {
type: 'separator';
}
export interface MultiSelectOptionGroup {
heading?: React.ReactNode;
value?: string;
children: MultiSelectOption[];
}
export type MultiSelectOption = {
value: unknown;
label: string;
};
export {
MultiSelect,
MultiSelectTrigger,
MultiSelectValue,
MultiSelectSearch,
MultiSelectContent,
MultiSelectList,
MultiSelectItem,
MultiSelectGroup,
MultiSelectSeparator,
MultiSelectEmpty,
};

View File

@@ -0,0 +1,69 @@
import { ReactNode } from 'react';
import { useEmbedding } from '@/components/embed-provider';
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar-shadcn';
interface PageHeaderProps {
title: ReactNode;
description?: ReactNode;
leftContent?: ReactNode;
rightContent?: ReactNode;
showBorder?: boolean;
className?: string;
hideSidebarTrigger?: boolean;
}
export const PageHeader = ({
title,
description,
leftContent,
rightContent,
showBorder = false,
className = '',
hideSidebarTrigger = false,
}: PageHeaderProps) => {
const { embedState } = useEmbedding();
if (embedState.hidePageHeader) {
return null;
}
const showSidebarTrigger = !hideSidebarTrigger && !embedState.isEmbedded;
return (
<div
className={`flex items-center justify-between py-3 w-full px-4 ${
showBorder ? 'border-b' : ''
} ${className}`}
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
{showSidebarTrigger && <SidebarTrigger />}
{showSidebarTrigger && (
<Separator orientation="vertical" className="h-5 mr-2" />
)}
<div>
<div className="flex items-center gap-2">
{typeof title === 'string' ? (
<h1 className="text-base font-normal">{title}</h1>
) : (
title
)}
</div>
{description && (
<span className="text-xs text-muted-foreground">
{description}
</span>
)}
</div>
{leftContent}
</div>
{rightContent}
</div>
</div>
);
};

View File

@@ -0,0 +1,26 @@
import { t } from 'i18next';
import React from 'react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
export const PermissionNeededTooltip = React.forwardRef<
HTMLButtonElement,
{ children: React.ReactNode; hasPermission: boolean }
>(({ children, hasPermission }, ref) => {
return (
<Tooltip delayDuration={100}>
<TooltipTrigger ref={ref} asChild disabled={!hasPermission}>
<div>{children}</div>
</TooltipTrigger>
{!hasPermission && (
<TooltipContent side="top">{t('Permission needed')}</TooltipContent>
)}
</Tooltip>
);
});
PermissionNeededTooltip.displayName = 'PermissionNeededWrapper';

View File

@@ -0,0 +1,68 @@
import { cn } from '@/lib/utils';
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
import { CardListItem } from './card-list';
export type RadioGroupListItem<T> = {
label: string;
value: T;
labelExtra?: React.ReactNode;
description?: string;
};
const RadioGroupList = <T,>({
items,
onChange,
value,
onHover,
className,
}: {
items: RadioGroupListItem<T>[];
onChange: (value: T) => void;
value: T | null;
onHover?: (value: T | null) => void;
className?: string;
}) => {
return (
<div className={cn('space-y-4', className)}>
<RadioGroup value={JSON.stringify(value)}>
{items.map((item, index) => {
const selected = item.value === value;
return (
<CardListItem
key={index}
className={cn(
`p-4 rounded-lg border block hover:border-primary/50 hover:bg-muted/50`,
{
'border-primary bg-primary/5': selected,
},
)}
onClick={() => onChange(item.value)}
onMouseEnter={() => onHover && onHover(item.value)}
onMouseLeave={() => onHover && onHover(null)}
>
<div className="flex justify-between items-center mb-2">
<h4 className="text-md font-medium flex items-center gap-2">
{item.label}
{item.labelExtra}
</h4>
<div className="shrink-0 w-5 h-5">
<RadioGroupItem
value={JSON.stringify(item.value)}
className="scale-125"
></RadioGroupItem>
</div>
</div>
<div className="text-sm text-muted-foreground">
{item.description}
</div>
</CardListItem>
);
})}
</RadioGroup>
</div>
);
};
RadioGroupList.displayName = 'RadioGroupList';
export { RadioGroupList };

View File

@@ -0,0 +1,284 @@
import deepEqual from 'deep-equal';
import { t } from 'i18next';
import { Check, ChevronsUpDown, RefreshCcw, X } from 'lucide-react';
import React, { useState, useRef } from 'react';
import { SelectUtilButton } from '@/components/custom/select-util-button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
type SelectOption<T> = {
value: T;
label: string;
description?: string;
};
type SearchableSelectProps<T> = {
options: SelectOption<T>[];
onChange: (value: T | null) => void;
value: T | undefined;
placeholder: string;
disabled?: boolean;
loading?: boolean;
showDeselect?: boolean;
onRefresh?: () => void;
showRefresh?: boolean;
onClose?: () => void;
triggerClassName?: string;
valuesRendering?: (value: unknown) => React.ReactNode;
openState?: {
open: boolean;
setOpen: (open: boolean) => void;
};
refreshOnSearch?: (searchValue: string) => void;
/**Use to show the selected option when search doesn't return the selected option */
cachedOptions?: {
value: T;
label: string;
}[];
};
const useOpenState = (openStateInitializer?: {
open: boolean;
setOpen: (open: boolean) => void;
}) => {
const [isOpen, setIsOpen] = useState(false);
if (openStateInitializer) {
return openStateInitializer;
}
return {
open: isOpen,
setOpen: setIsOpen,
};
};
export const SearchableSelect = <T,>({
options,
onChange,
value,
placeholder,
disabled,
loading,
showDeselect,
onRefresh,
showRefresh,
onClose,
triggerClassName,
valuesRendering,
openState: openStateInitializer,
refreshOnSearch,
cachedOptions = [],
}: SearchableSelectProps<T>) => {
const triggerRef = useRef<HTMLButtonElement>(null);
const [searchTerm, setSearchTerm] = useState('');
const { open, setOpen } = useOpenState(openStateInitializer);
const triggerWidth = `${triggerRef.current?.clientWidth ?? 0}px`;
const selectedOption =
[...cachedOptions, ...options].find((option) =>
deepEqual(option.value, value),
) ?? undefined;
const filterOptionsIndices = options
.map((option, index) => {
return {
label: option.label,
value: option.value,
index: index,
description: option.description ?? '',
};
})
.filter((option) => {
if (refreshOnSearch || searchTerm.length === 0) {
return true;
}
return (
option.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
option.description.toLowerCase().includes(searchTerm.toLowerCase())
);
})
.map((option) => option.index);
const onSelect = (index: string) => {
const optionIndex =
Number.isInteger(parseInt(index)) && !Number.isNaN(parseInt(index))
? parseInt(index)
: -1;
setSearchTerm('');
if (optionIndex === -1) {
return;
}
const option = options[optionIndex];
onChange(option.value);
};
return (
<Popover
modal={true}
open={open}
onOpenChange={(open) => {
if (!open) {
onClose?.();
}
if (refreshOnSearch && searchTerm.length > 0) {
refreshOnSearch('');
setSearchTerm('');
}
setOpen(open);
}}
>
<PopoverTrigger
asChild
className={cn({
'cursor-not-allowed opacity-80 ': disabled,
})}
onClick={(e) => {
if (disabled) {
e.preventDefault();
}
e.stopPropagation();
}}
>
<div className="relative">
<Button
ref={triggerRef}
variant="outline"
disabled={disabled}
role="combobox"
loading={loading}
aria-expanded={open}
className={cn('w-full justify-between', triggerClassName)}
onClick={(e) => {
setOpen(!open);
e.preventDefault();
}}
>
<span className="flex w-full truncate select-none">
{selectedOption
? valuesRendering
? valuesRendering(selectedOption.value)
: selectedOption.label
: placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
<div className="right-10 top-2 absolute flex gap-2 z-50 items-center">
{showDeselect && !disabled && selectedOption && !loading && (
<SelectUtilButton
tooltipText={t('Unset')}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onChange(null);
}}
Icon={X}
></SelectUtilButton>
)}
{showRefresh && !loading && (
<SelectUtilButton
tooltipText={t('Refresh')}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
if (onRefresh) {
onRefresh();
}
}}
Icon={RefreshCcw}
></SelectUtilButton>
)}
</div>
</div>
</PopoverTrigger>
<PopoverContent
style={{
maxWidth: triggerWidth,
minWidth: triggerWidth,
}}
className="min-w-full w-full p-0"
>
<Command className="w-full" shouldFilter={false}>
<CommandInput
placeholder={t(placeholder)}
value={searchTerm}
onValueChange={(e) => {
setSearchTerm(e);
if (refreshOnSearch) {
refreshOnSearch(e);
}
}}
/>
{filterOptionsIndices.length === 0 && (
<CommandEmpty>{t('No results found.')}</CommandEmpty>
)}
<CommandGroup>
<CommandList>
<ScrollArea
className="h-full"
viewPortClassName={'max-h-[200px]'}
>
{filterOptionsIndices &&
!loading &&
filterOptionsIndices.map((filterIndex) => {
const option = options[filterIndex];
if (!option) {
return null;
}
return (
<CommandItem
key={filterIndex}
value={String(filterIndex)}
onSelect={(currentValue) => {
setOpen(false);
onSelect(currentValue);
}}
className="flex gap-2 flex-col items-start"
>
<div className="flex gap-2 items-center justify-between w-full">
{option.label === '' ? (
<span className="">&nbsp;</span>
) : valuesRendering ? (
valuesRendering(option.value)
) : (
option.label
)}
<Check
className={cn('shrink-0 w-4 h-4', {
hidden: selectedOption?.value !== option.value,
})}
/>
</div>
{option.description && (
<div className="text-sm text-muted-foreground">
{option.description}
</div>
)}
</CommandItem>
);
})}
{loading && (
<CommandItem disabled>{t('Loading...')}</CommandItem>
)}
</ScrollArea>
</CommandList>
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
};
SearchableSelect.displayName = 'SearchableSelect';

View File

@@ -0,0 +1,36 @@
import { TooltipTrigger } from '@radix-ui/react-tooltip';
import { LucideIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent } from '@/components/ui/tooltip';
const SelectUtilButton = ({
onClick,
Icon,
tooltipText,
}: {
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
Icon: LucideIcon;
tooltipText?: string;
}) => {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
className="opacity-50 shrink-0 h-6 w-6 rounded-xs"
size={'icon'}
onClick={onClick}
>
<Icon className="w-4 h-4"></Icon>
</Button>
</TooltipTrigger>
{tooltipText && (
<TooltipContent side="bottom">{tooltipText}</TooltipContent>
)}
</Tooltip>
);
};
SelectUtilButton.displayName = 'SelectUtilButton';
export { SelectUtilButton };

View File

@@ -0,0 +1,55 @@
import React, { useRef, useState, useCallback, useEffect } from 'react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
interface TextWithTooltipProps {
tooltipMessage: string;
children: React.ReactElement<
React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> }
>;
}
export const TextWithTooltip = ({
tooltipMessage,
children,
}: TextWithTooltipProps) => {
const textRef = useRef<HTMLDivElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
const checkTruncation = useCallback(() => {
if (textRef.current) {
setIsTruncated(textRef.current.scrollWidth > textRef.current.clientWidth);
}
}, []);
useEffect(() => {
checkTruncation();
window.addEventListener('resize', checkTruncation);
return () => window.removeEventListener('resize', checkTruncation);
}, [checkTruncation]);
const childWithRef = React.cloneElement(children, {
ref: textRef,
className: cn('truncate', children.props.className),
});
if (!isTruncated) {
return childWithRef;
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{childWithRef}</TooltipTrigger>
<TooltipContent className="max-w-md wrap-break-word whitespace-normal">
<p>{tooltipMessage}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};

View File

@@ -0,0 +1,49 @@
import React from 'react';
type DataListProps = {
data?: Record<string, any>;
className?: string;
};
function formatValue(value: any): string {
if (Array.isArray(value)) return value.join(', ');
if (value === null || value === undefined) return '';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
export const DataList: React.FC<DataListProps> = ({
data = {},
className = '',
}) => {
const entries = Object.entries(data).filter(
([_, value]) => value !== null && value !== undefined,
);
if (entries.length === 0) {
return (
<div className={`text-sm text-muted-foreground italic ${className}`}>
No data available
</div>
);
}
return (
<dl
className={`grid gap-y-2 text-sm leading-relaxed ${className}`}
style={{ wordBreak: 'break-word' }}
>
{entries.map(([key, value]) => (
<div
key={key}
className="grid grid-cols-[auto_1fr] gap-x-3 items-start"
>
<dt className="font-medium text-muted-foreground capitalize">
{key}
</dt>
<dd className="text-primary">{formatValue(value)}</dd>
</div>
))}
</dl>
);
};

View File

@@ -0,0 +1,103 @@
import { useMutation } from '@tanstack/react-query';
import { t } from 'i18next';
import { TriangleAlert } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
interface ConfirmationDeleteDialogProps {
title: string;
message: React.ReactNode | string;
mutationFn: () => Promise<void>;
entityName: string;
children?: React.ReactNode;
open?: boolean;
isDanger?: boolean;
buttonText?: string;
onOpenChange?: (open: boolean) => void;
showToast?: boolean;
onError?: (error: Error) => void;
}
export const ConfirmationDeleteDialog = ({
title,
message,
mutationFn,
showToast,
isDanger,
entityName,
buttonText,
children,
open,
onError,
onOpenChange,
}: ConfirmationDeleteDialogProps) => {
const [isControlled] = useState(
open !== undefined && onOpenChange !== undefined,
);
const [isUncontrolledOpen, setIsUncontrolledOpen] = useState(false);
const { mutate, isPending } = useMutation({
mutationFn,
onSuccess: () => {
handleClose();
if (showToast) {
toast.success(t('Removed {entityName}', { entityName }));
}
},
onError,
});
const handleClose = () => {
if (isControlled) {
onOpenChange?.(false);
} else {
setIsUncontrolledOpen(false);
}
};
const isOpen = isControlled ? open : isUncontrolledOpen;
return (
<Dialog
open={isOpen}
onOpenChange={isControlled ? onOpenChange : setIsUncontrolledOpen}
>
{children && <DialogTrigger asChild>{children}</DialogTrigger>}
<DialogContent onClick={(e) => e.stopPropagation()}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="pt-2">{message}</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-4">
<Button
variant="outline"
disabled={isPending}
onClick={() => handleClose()}
>
{t('Cancel')}
</Button>
<Button
variant="destructive"
loading={isPending}
onClick={() => mutate()}
>
{isDanger && <TriangleAlert className="size-4 mr-2" />}
{buttonText || t('Remove')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,76 @@
import React, { createContext, useContext, useState } from 'react';
import { cn } from '@/lib/utils';
type EmbeddingState = {
isEmbedded: boolean;
hideSideNav: boolean;
hideFlowsPageNavbar: boolean;
disableNavigationInBuilder: boolean;
hideFolders: boolean;
hideFlowNameInBuilder: boolean;
hideExportAndImportFlow: boolean;
sdkVersion?: string;
predefinedConnectionName?: string;
fontUrl?: string;
fontFamily?: string;
useDarkBackground: boolean;
hideHomeButtonInBuilder: boolean;
emitHomeButtonClickedEvent: boolean;
homeButtonIcon: 'back' | 'logo';
hideDuplicateFlow: boolean;
hidePageHeader: boolean;
};
const defaultState: EmbeddingState = {
isEmbedded: false,
hideSideNav: false,
hideFlowsPageNavbar: false,
disableNavigationInBuilder: false,
hideFolders: false,
hideFlowNameInBuilder: false,
hideExportAndImportFlow: false,
useDarkBackground: window.opener !== null,
hideHomeButtonInBuilder: false,
emitHomeButtonClickedEvent: false,
homeButtonIcon: 'logo',
hideDuplicateFlow: false,
hidePageHeader: false,
};
const EmbeddingContext = createContext<{
embedState: EmbeddingState;
setEmbedState: React.Dispatch<React.SetStateAction<EmbeddingState>>;
}>({
embedState: defaultState,
setEmbedState: () => {},
});
export const useEmbedding = () => useContext(EmbeddingContext);
type EmbeddingProviderProps = {
children: React.ReactNode;
};
const EmbeddingProvider = ({ children }: EmbeddingProviderProps) => {
const [state, setState] = useState<EmbeddingState>(defaultState);
return (
<EmbeddingContext.Provider
value={{ embedState: state, setEmbedState: setState }}
>
<div
className={cn({
'bg-black/80 h-screen w-screen':
state.useDarkBackground && state.isEmbedded,
})}
>
{children}
</div>
</EmbeddingContext.Provider>
);
};
EmbeddingProvider.displayName = 'EmbeddingProvider';
export { EmbeddingProvider };

View File

@@ -0,0 +1,253 @@
import { t } from 'i18next';
import { Copy, Download, Eye, EyeOff } from 'lucide-react';
import React, { useLayoutEffect, useMemo } from 'react';
import { createRoot } from 'react-dom/client';
import ReactJson from 'react-json-view';
import { toast } from 'sonner';
import { useTheme } from '@/components/theme-provider';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn, isStepFileUrl } from '@/lib/utils';
import { isNil } from '@activepieces/shared';
import { Button } from './ui/button';
type JsonViewerProps = {
json: any;
title: string;
hideDownload?: boolean;
className?: string;
};
type FileButtonProps = {
fileUrl: string;
handleDownloadFile: (fileUrl: string) => void;
};
const FileButton = ({ fileUrl, handleDownloadFile }: FileButtonProps) => {
const readonly = fileUrl.includes('file://');
return (
<div className="flex items-center gap-0">
<Button
variant="ghost"
size="sm"
disabled={readonly}
onClick={() => handleDownloadFile(fileUrl)}
className="flex items-center gap-2 p-2 max-h-[20px] text-xs"
>
{readonly ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
{t('Download File')}
</Button>
</div>
);
};
const removeDoubleQuotes = (str: string): string =>
str.startsWith('"') && str.endsWith('"') ? str.slice(1, -1) : str;
const removeUndefined = (obj: any): any => {
if (Array.isArray(obj)) {
return obj.map(removeUndefined);
} else if (typeof obj === 'object' && obj !== null) {
return Object.fromEntries(
Object.entries(obj)
.filter(([_, value]) => value !== undefined)
.map(([key, value]) => [key, removeUndefined(value)]),
);
}
return obj;
};
const JsonViewer = React.memo(
({
json: unclearJson,
title,
hideDownload = false,
className,
}: JsonViewerProps) => {
const { theme } = useTheme();
const json = useMemo(() => {
return removeUndefined(unclearJson);
}, [unclearJson]);
const viewerTheme = theme === 'dark' ? 'bright' : 'rjv-default';
const handleCopy = () => {
navigator.clipboard.writeText(JSON.stringify(json, null, 2));
toast.success(t('Copied to clipboard'), {
duration: 1000,
});
};
const handleDownload = () => {
const blob = new Blob([JSON.stringify(json, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
handleDownloadFile(url);
};
const handleDownloadFile = (fileUrl: string, ext = '') => {
const link = document.createElement('a');
link.href = fileUrl;
link.download = `${title}${ext}`;
link.click();
URL.revokeObjectURL(fileUrl);
};
useLayoutEffect(() => {
if (typeof json === 'object') {
const stringValuesHTML = Array.from(
document.getElementsByClassName('string-value'),
);
const stepFileUrlsHTML = stringValuesHTML.filter(
(el) =>
isStepFileUrl(el.innerHTML) ||
isStepFileUrl(el.parentElement!.nextElementSibling?.innerHTML),
);
stepFileUrlsHTML.forEach((el: Element) => {
const fileUrl = removeDoubleQuotes(el.innerHTML)
.trim()
.replace('\n', '');
el.className += ' hidden';
const rootElem = document.createElement('div');
const root = createRoot(rootElem);
el.parentElement!.replaceChildren(el as Node, rootElem as Node);
const isProductionFile = fileUrl.includes('file://');
root.render(
<div data-file-root="true">
{isProductionFile ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<FileButton
fileUrl={fileUrl}
handleDownloadFile={handleDownloadFile}
/>
</TooltipTrigger>
<TooltipContent side="right">
{t('File is not available after execution.')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<FileButton
fileUrl={fileUrl}
handleDownloadFile={handleDownloadFile}
/>
)}
</div>,
);
});
}
});
if (isStepFileUrl(json)) {
return (
<FileButton fileUrl={json} handleDownloadFile={handleDownloadFile} />
);
}
return (
<div
className={cn(
'rounded-lg border border-solid border-dividers overflow-hidden relative',
className,
)}
>
<div className="px-3 py-2 flex border-solid border-b border-dividers justify-center items-center">
<div className="grow justify-center items-center">
<span className="text-md">{title}</span>
</div>
<div className="flex items-center gap-0">
{!hideDownload && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={'ghost'}
size={'sm'}
onClick={handleDownload}
>
<Download className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{t('Download JSON')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant={'ghost'} size={'sm'} onClick={handleCopy}>
<Copy className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{t('Copy to clipboard')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{
<>
{isNil(json) ? (
<pre className="text-sm whitespace-pre-wrap overflow-x-auto p-2">
{json === null ? 'null' : 'undefined'}
</pre>
) : (
<>
{typeof json !== 'string' && typeof json !== 'object' && (
<pre className="text-sm whitespace-pre-wrap break-all overflow-x-auto p-2">
{JSON.stringify(json)}
</pre>
)}
{typeof json === 'string' && (
<pre className="text-sm whitespace-pre-wrap break-all overflow-x-auto p-2">
{json}
</pre>
)}
{typeof json === 'object' && (
<div className="max-w-full">
<ReactJson
style={{
overflowX: 'auto',
padding: '0.5rem',
wordBreak: 'break-word',
}}
theme={viewerTheme}
enableClipboard={false}
groupArraysAfterLength={20}
displayDataTypes={false}
name={false}
quotesOnKeys={false}
src={json}
/>
</div>
)}
</>
)}
</>
}
</div>
);
},
);
JsonViewer.displayName = 'JsonViewer';
export { JsonViewer };

View File

@@ -0,0 +1,45 @@
import { cn } from '../lib/utils';
type ShowPoweredByProps = {
show: boolean;
position?: 'sticky' | 'absolute' | 'static';
};
const ShowPoweredBy = ({ show, position = 'sticky' }: ShowPoweredByProps) => {
if (!show) {
return null;
}
return (
<div
className={cn('bottom-3 right-5 pointer-events-none z-10000', position, {
'-mt-[30px]': position === 'sticky',
'mr-5': position === 'sticky',
})}
>
<div
className={cn(
'justify-end p-1 text-muted-foreground/70 text-sm items-center flex gap-1 transition group ',
{
'justify-center': position === 'static',
},
)}
>
<div className=" text-sm transition">Built with</div>
<div className="justify-center flex items-center gap-1">
<svg
width={15}
height={15}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
className="transition fill-muted-foreground/70"
>
<path d="M6.46013 5.81759C5.30809 4.10962 5.75876 1.79113 7.46672 0.639093C9.17469 -0.512944 11.4932 -0.0622757 12.6452 1.64569L20.4261 13.1813C21.5781 14.8893 21.1274 17.2077 19.4195 18.3598C17.7115 19.5118 15.393 19.0611 14.241 17.3532L10.8676 12.3519C10.4339 11.8054 9.55114 11.8905 9.02108 12.4205C8.58152 12.8601 8.43761 13.9846 8.31301 14.9582C8.29474 15.1009 8.27689 15.2405 8.25858 15.3741C8.19097 16.0114 7.97092 16.6418 7.58762 17.2101C6.33511 19.067 3.81375 19.5565 1.95682 18.304C0.0998936 17.0515 -0.390738 14.5304 0.861776 12.6734C1.51136 11.7104 2.50224 11.1151 3.56472 10.9399L3.56322 10.9384C6.63307 10.4932 7.20222 7.02864 6.64041 6.08487L6.46013 5.81759Z" />
</svg>
<div className="font-semibold">activepieces</div>
</div>
</div>
</div>
);
};
ShowPoweredBy.displayName = 'ShowPoweredBy';
export { ShowPoweredBy };

View File

@@ -0,0 +1,117 @@
import { t } from 'i18next';
import { Copy, Check } from 'lucide-react';
import React, { useState } from 'react';
import ReactJson from 'react-json-view';
import { toast } from 'sonner';
import { useTheme } from './theme-provider';
import { Button } from './ui/button';
interface SimpleJsonViewerProps {
data: any;
readOnly?: boolean;
hideCopyButton?: boolean;
maxHeight?: number;
}
export const SimpleJsonViewer: React.FC<SimpleJsonViewerProps> = ({
data,
readOnly = true,
hideCopyButton = false,
maxHeight = 400,
}) => {
const [copied, setCopied] = useState(false);
const { theme } = useTheme();
const formattedJson =
typeof data === 'string' ? data : JSON.stringify(data, null, 2);
const handleCopy = () => {
navigator.clipboard.writeText(formattedJson);
setCopied(true);
toast.success(t('Copied to clipboard'), {
duration: 1000,
});
setTimeout(() => {
setCopied(false);
}, 3000);
};
const viewerTheme = theme === 'dark' ? 'bright' : 'rjv-default';
return (
<div
className="w-full relative text-foreground overflow-hidden"
style={{
maxWidth: '100%',
}}
>
{!hideCopyButton && (
<div className="absolute top-2 right-5 z-10">
<Button
variant="transparent"
size="sm"
onClick={handleCopy}
className="p-0 "
>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy
className={`w-4 h-4 ${
theme === 'dark' ? 'text-white' : 'text-black'
}`}
/>
)}
</Button>
</div>
)}
<div
className="p-2"
style={{
maxHeight: typeof maxHeight === 'number' ? `${maxHeight}px` : '400px',
overflow: 'auto',
overflowX: 'auto',
width: '100%',
boxSizing: 'border-box',
}}
>
{typeof data === 'string' ? (
<pre className="text-sm whitespace-pre-wrap break-all overflow-x-auto p-2">
{data}
</pre>
) : (
<div style={{ minWidth: 0, width: '100%', height: '100%' }}>
<ReactJson
style={{
overflowX: 'auto',
padding: '0.5rem',
fontSize: '14px',
width: '100%',
minWidth: 0,
boxSizing: 'border-box',
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
}}
theme={viewerTheme}
enableClipboard={false}
groupArraysAfterLength={20}
displayDataTypes={false}
name={false}
quotesOnKeys={false}
src={data}
collapsed={false}
displayObjectSize={false}
iconStyle="triangle"
shouldCollapse={false}
onEdit={readOnly ? false : undefined}
onAdd={readOnly ? false : undefined}
onDelete={readOnly ? false : undefined}
/>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,60 @@
import React, { useRef } from 'react';
import { useEffectOnce } from 'react-use';
import { io } from 'socket.io-client';
import { toast } from 'sonner';
import { API_BASE_URL } from '@/lib/api';
import { authenticationSession } from '@/lib/authentication-session';
const socket = io(API_BASE_URL, {
transports: ['websocket'],
path: '/api/socket.io',
autoConnect: false,
reconnection: true,
});
const SocketContext = React.createContext<typeof socket>(socket);
export const SocketProvider = ({ children }: { children: React.ReactNode }) => {
const token = authenticationSession.getToken();
const toastIdRef = useRef<string | null>(null);
useEffectOnce(() => {
if (token) {
socket.auth = { token };
if (!socket.connected) {
socket.connect();
socket.on('connect', () => {
if (toastIdRef.current) {
toast.dismiss(toastIdRef.current);
toastIdRef.current = null;
}
console.log('connected to socket');
});
socket.on('disconnect', (reason) => {
if (!toastIdRef.current) {
const id = toast('Connection Lost', {
id: 'websocket-disconnected',
description: 'We are trying to reconnect...',
duration: Infinity,
});
toastIdRef.current = id?.toString() ?? null;
}
if (reason === 'io server disconnect') {
socket.connect();
}
});
}
} else {
socket.disconnect();
}
});
return (
<SocketContext.Provider value={socket}>{children}</SocketContext.Provider>
);
};
export const useSocket = () => React.useContext(SocketContext);

View File

@@ -0,0 +1,143 @@
import { AnalyticsBrowser } from '@segment/analytics-next';
import posthog from 'posthog-js';
import React, { useEffect, useState, useRef } from 'react';
import { useDeepCompareEffect } from 'react-use';
import { flagsHooks } from '@/hooks/flags-hooks';
import { userHooks } from '@/hooks/user-hooks';
import {
ApFlagId,
isNil,
TelemetryEvent,
UserWithMetaInformationAndProject,
} from '@activepieces/shared';
interface TelemetryProviderProps {
children: React.ReactNode;
}
const TelemetryProvider = ({ children }: TelemetryProviderProps) => {
const { data: currentUser } = userHooks.useCurrentUser();
const [analytics, setAnalytics] = useState<AnalyticsBrowser | null>(null);
const initializedUserEmail = useRef<string | null>(null);
const [user, setUser] = useState<UserWithMetaInformationAndProject | null>(
currentUser ?? null,
);
const { data: telemetryEnabled } = flagsHooks.useFlag<boolean>(
ApFlagId.TELEMETRY_ENABLED,
);
const { data: flagCurrentVersion } = flagsHooks.useFlag<string>(
ApFlagId.CURRENT_VERSION,
);
const { data: flagEnvironment } = flagsHooks.useFlag<string>(
ApFlagId.ENVIRONMENT,
);
useEffect(() => {
const handleStorageChange = (_event: StorageEvent) => {
setUser(currentUser ?? null);
};
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, []);
useDeepCompareEffect(() => {
if (isNil(user)) {
return;
}
if (telemetryEnabled && user?.email !== initializedUserEmail.current) {
initTelemetry();
}
}, [telemetryEnabled, user]);
const initTelemetry = () => {
if (isNil(user)) {
return;
}
console.log('Telemetry enabled');
const newAnalytics = AnalyticsBrowser.load({
writeKey: 'Znobm6clOFLZNdMFpZ1ncf6VDmlCVSmj',
});
newAnalytics.addSourceMiddleware(({ payload, next }) => {
const path = payload?.obj?.properties?.['path'];
const ignoredPaths = ['/embed'];
if (ignoredPaths.includes(path)) {
return;
}
next(payload);
});
const currentVersion = flagCurrentVersion || '0.0.0';
const environment = flagEnvironment || '0.0.0';
newAnalytics.identify(user.id, {
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
activepiecesVersion: currentVersion,
activepiecesEnvironment: environment,
ui: 'react',
});
newAnalytics.ready(() => {
posthog.init('phc_7F92HoXJPeGnTKmYv0eOw62FurPMRW9Aqr0TPrDzvHh', {
autocapture: false,
capture_pageview: false,
segment: (window as any).analytics,
loaded: () => newAnalytics.page(),
});
posthog.identify(user.id, {
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
activepiecesVersion: currentVersion,
activepiecesEnvironment: environment,
});
});
setAnalytics(newAnalytics);
initializedUserEmail.current = user.email;
};
const reset = () => {
if (analytics) {
analytics.reset();
}
posthog.reset();
console.log('Telemetry removed');
initializedUserEmail.current = null;
};
const capture = (event: TelemetryEvent) => {
if (telemetryEnabled && analytics) {
analytics.track(event.name, event.payload);
}
};
return (
<TelemetryContext.Provider value={{ capture, reset }}>
{children}
</TelemetryContext.Provider>
);
};
interface TelemetryContextType {
capture: (event: TelemetryEvent) => void;
reset: () => void;
}
const TelemetryContext = React.createContext<TelemetryContextType>({
capture: () => {},
reset: () => {},
});
export const useTelemetry = () => React.useContext(TelemetryContext);
export default TelemetryProvider;

View File

@@ -0,0 +1,127 @@
import { createContext, useContext, useEffect, useState } from 'react';
import * as RippleHook from 'use-ripple-hook';
import { flagsHooks } from '@/hooks/flags-hooks';
import { colorsUtils } from '@/lib/color-util';
type Theme = 'dark' | 'light' | 'system';
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const initialState: ThemeProviderState = {
theme: 'system',
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
const setFavicon = (url: string) => {
let link: HTMLLinkElement | null =
document.querySelector("link[rel*='icon']");
if (!link) {
link = document.createElement('link');
link.rel = 'shortcut icon';
document.head.appendChild(link);
}
link.href = url;
};
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'ap-ui-theme',
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
);
const branding = flagsHooks.useWebsiteBranding();
useEffect(() => {
if (!branding) {
console.warn('Website brand is not defined');
return;
}
const root = window.document.documentElement;
const resolvedTheme = theme === 'system' ? 'light' : theme;
root.classList.remove('light', 'dark');
document.title = branding.websiteName;
document.documentElement.style.setProperty(
'--primary',
colorsUtils.hexToHslString(branding.colors.primary.default),
);
setFavicon(branding.logos.favIconUrl);
switch (resolvedTheme) {
case 'light': {
document.documentElement.style.setProperty(
'--primary-100',
colorsUtils.hexToHslString(branding.colors.primary.light),
);
document.documentElement.style.setProperty(
'--primary-300',
colorsUtils.hexToHslString(branding.colors.primary.dark),
);
break;
}
case 'dark': {
document.documentElement.style.setProperty(
'--primary-100',
colorsUtils.hexToHslString(branding.colors.primary.dark),
);
document.documentElement.style.setProperty(
'--primary-300',
colorsUtils.hexToHslString(branding.colors.primary.light),
);
break;
}
default:
break;
}
root.classList.add(resolvedTheme);
}, [theme, branding]);
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error('useTheme must be used within a ThemeProvider');
return context;
};
export const useApRipple = () => {
const { theme } = useTheme();
return RippleHook.default({
color:
theme === 'dark'
? 'rgba(233, 233, 233, 0.2)'
: 'rgba(155, 155, 155, 0.2)',
cancelAutomatically: true,
});
};

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 };

Some files were not shown because too many files have changed in this diff Show More