feat: dashboard v2, esm, upgrades (#211)

* esm

* wip

* wip

* wip

* wip

* wip

* wip

* subscription notice

* wip

* wip

* wip

* fix envs

* fix: update docker build

* fix

* esm/types

* delete dashboard :D

* add patches to dockerfiles

* update packages + catalogs + ts

* wip

* remove native libs

* ts

* improvements

* fix redirects and fetching session

* try fix favicon

* fixes

* fix

* order and resize reportds within a dashboard

* improvements

* wip

* added userjot to dashboard

* fix

* add op

* wip

* different cache key

* improve date picker

* fix table

* event details loading

* redo onboarding completely

* fix login

* fix

* fix

* extend session, billing and improve bars

* fix

* reduce price on 10M
This commit is contained in:
Carl-Gerhard Lindesvärd
2025-10-16 12:27:44 +02:00
committed by GitHub
parent 436e81ecc9
commit 81a7e5d62e
741 changed files with 32695 additions and 16996 deletions

View File

@@ -0,0 +1,55 @@
import { cn } from '@/utils/cn';
import { Asterisk, ChevronRight } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip';
interface RenderDotsProps extends React.HTMLAttributes<HTMLDivElement> {
children: string;
truncate?: boolean;
}
export function RenderDots({
children,
className,
truncate,
...props
}: RenderDotsProps) {
const parts = children.split('.');
const sliceAt = truncate && parts.length > 3 ? 3 : 0;
return (
<Tooltip
disableHoverableContent={true}
open={sliceAt === 0 ? false : undefined}
>
<TooltipTrigger>
<div {...props} className={cn('flex items-center gap-1', className)}>
{parts.slice(-sliceAt).map((str, index) => {
return (
<div
className="flex items-center gap-1"
key={str + (index as number)}
>
{index !== 0 && (
<ChevronRight className="relative top-[0.9px] !h-3 !w-3 flex-shrink-0" />
)}
{str.includes('[*]') ? (
<>
{str.replace('[*]', '')}
<Asterisk className="relative top-[0.9px] !h-3 !w-3 flex-shrink-0" />
</>
) : str === '*' ? (
<Asterisk className="relative top-[0.9px] !h-3 !w-3 flex-shrink-0" />
) : (
str
)}
</div>
);
})}
</div>
</TooltipTrigger>
<TooltipContent align="start">
<p>{children}</p>
</TooltipContent>
</Tooltip>
);
}

View File

@@ -0,0 +1,55 @@
import { cn } from '@/utils/cn';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDown } from 'lucide-react';
import * as React from 'react';
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn('border-b [&:last-child]:border-b-0', 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 items-center justify-between py-4 font-medium transition-all [&[data-state=closed]]:hover:bg-muted/30 [&[data-state=open]>svg]:rotate-180',
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 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="overflow-hidden transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn('pb-4 pt-0', className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -0,0 +1,138 @@
import { buttonVariants } from '@/components/ui/button';
import { cn } from '@/utils/cn';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import * as React from 'react';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, children, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-card/80 backdrop-blur-sm 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}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card 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 md:w-full',
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className,
)}
{...props}
/>
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn(' text-muted-foreground', className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
className,
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -0,0 +1,63 @@
import { cn } from '@/utils/cn';
import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
import * as React from 'react';
const alertVariants = cva(
'relative w-full rounded-lg border p-4 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7',
{
variants: {
variant: {
default: 'bg-card text-foreground',
destructive:
'border-destructive text-destructive dark:border-destructive [&>svg]:text-destructive',
warning:
'bg-orange-400/10 border-orange-400 text-orange-600 dark:border-orange-400 [&>svg]:text-orange-400',
},
},
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, children, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
>
{children}
</h5>
));
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(' [&_p]:leading-relaxed', className)}
{...props}
/>
));
AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio';
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };

View File

@@ -0,0 +1,47 @@
import { cn } from '@/utils/cn';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import * as React from 'react';
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-8 w-8 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 bg-primary text-white',
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,46 @@
import { Slot } from '@radix-ui/react-slot';
import { type VariantProps, cva } from 'class-variance-authority';
import type * as React from 'react';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none 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 transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span';
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,219 @@
import { cn } from '@/utils/cn';
import { Slot } from '@radix-ui/react-slot';
import { Link, type LinkComponentProps } from '@tanstack/react-router';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import type { LucideIcon } from 'lucide-react';
import * as React from 'react';
import { Spinner, type SpinnerProps } from './spinner';
const buttonVariants = cva(
'inline-flex flex-shrink-0 select-none items-center justify-center whitespace-nowrap rounded-md font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:translate-y-[-0.5px] transition-all',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
cta: 'bg-highlight text-white hover:bg-highlight/80',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-card hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-def-100 text-secondary-foreground hover:bg-def-100/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-8 rounded-md px-2',
lg: 'h-11 rounded-md px-8',
icon: 'h-8 w-8',
},
},
defaultVariants: {
variant: 'default',
size: 'sm',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
loading?: boolean;
loadingType?: SpinnerProps['type'];
loadingSpeed?: SpinnerProps['speed'];
icon?: LucideIcon;
responsive?: boolean;
autoHeight?: boolean;
loadingAbsolute?: boolean;
}
function fixHeight({
autoHeight,
size,
}: { autoHeight?: boolean; size: ButtonProps['size'] }) {
if (autoHeight) {
switch (size) {
case 'lg':
return 'h-auto min-h-11 py-2';
case 'icon':
return 'h-auto min-h-8 py-1';
case 'default':
return 'h-auto min-h-10 py-2';
default:
return 'h-auto min-h-8 py-1';
}
}
return '';
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant,
size,
asChild = false,
children,
loading,
loadingType = 'circle',
loadingSpeed = 'normal',
disabled,
icon,
responsive,
autoHeight,
loadingAbsolute,
...props
},
ref,
) => {
const Comp = asChild ? Slot : 'button';
const Icon = loading ? null : (icon ?? null);
// Determine spinner size based on button size
const spinnerSize = size === 'lg' ? 'md' : size === 'icon' ? 'sm' : 'sm';
return (
<Comp
className={cn(
buttonVariants({ variant, size, className }),
fixHeight({ autoHeight, size }),
loadingAbsolute && 'relative',
)}
ref={ref}
disabled={loading || disabled}
{...props}
>
{loading && (
<div
className={cn(
loadingAbsolute &&
'absolute top-0 left-0 right-0 bottom-0 center-center backdrop-blur bg-background/10',
)}
>
<Spinner
type={loadingType}
size={spinnerSize}
speed={loadingSpeed}
variant={
variant === 'default' || variant === 'cta' ? 'white' : 'default'
}
className={cn(
'flex-shrink-0',
size !== 'icon' && responsive && 'mr-0 sm:mr-2',
size !== 'icon' && !responsive && 'mr-2',
)}
/>
</div>
)}
{Icon && (
<Icon
className={cn(
'h-4 w-4 flex-shrink-0',
size !== 'icon' && responsive && 'mr-0 sm:mr-2',
size !== 'icon' && !responsive && 'mr-2',
)}
/>
)}
{responsive ? (
<span className="hidden sm:block">{children}</span>
) : (
children
)}
</Comp>
);
},
);
Button.displayName = 'Button';
const LinkButton = ({
className,
variant,
size,
children,
loading,
loadingType = 'circle',
loadingSpeed = 'normal',
icon,
responsive,
...props
}: LinkComponentProps &
VariantProps<typeof buttonVariants> & {
loading?: boolean;
loadingType?: SpinnerProps['type'];
loadingSpeed?: SpinnerProps['speed'];
icon?: LucideIcon;
responsive?: boolean;
}) => {
const Icon = loading ? null : (icon ?? null);
// Determine spinner size based on button size
const spinnerSize = size === 'lg' ? 'md' : size === 'icon' ? 'sm' : 'sm';
return (
<Link
className={cn(buttonVariants({ variant, size, className }))}
{...props}
>
{(linkProps) => (
<>
{loading && (
<Spinner
type={loadingType}
size={spinnerSize}
speed={loadingSpeed}
variant={
variant === 'default' || variant === 'cta' ? 'white' : 'default'
}
className={cn(
'flex-shrink-0',
responsive && 'mr-0 sm:mr-2',
!responsive && 'mr-2',
)}
/>
)}
{Icon && (
<Icon
className={cn(
'mr-2 h-4 w-4 flex-shrink-0',
responsive && 'mr-0 sm:mr-2',
)}
/>
)}
{responsive ? (
<span className="hidden sm:block">
{typeof children === 'function' ? children(linkProps) : children}
</span>
) : typeof children === 'function' ? (
children(linkProps)
) : (
children
)}
</>
)}
</Link>
);
};
export { Button, LinkButton, buttonVariants };

View File

@@ -0,0 +1,215 @@
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from 'lucide-react';
import * as React from 'react';
import {
type DayButton,
DayPicker,
getDefaultClassNames,
} from 'react-day-picker';
import { Button, buttonVariants } from '@/components/ui/button';
import { cn } from '@/lib/utils';
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = 'label',
buttonVariant = 'ghost',
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
'bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString('default', { month: 'short' }),
...formatters,
}}
classNames={{
root: cn('w-fit', defaultClassNames.root),
months: cn(
'flex gap-4 flex-col sm:flex-row relative',
defaultClassNames.months,
),
month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
nav: cn(
'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
defaultClassNames.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
defaultClassNames.button_next,
),
month_caption: cn(
'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
defaultClassNames.month_caption,
),
dropdowns: cn(
'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
defaultClassNames.dropdowns,
),
dropdown_root: cn(
'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
defaultClassNames.dropdown_root,
),
dropdown: cn(
'absolute bg-popover inset-0 opacity-0',
defaultClassNames.dropdown,
),
caption_label: cn(
'select-none font-medium',
captionLayout === 'label'
? 'text-sm'
: 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
defaultClassNames.caption_label,
),
table: 'w-full border-collapse',
weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn(
'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
defaultClassNames.weekday,
),
week: cn('flex w-full mt-2', defaultClassNames.week),
week_number_header: cn(
'select-none w-(--cell-size)',
defaultClassNames.week_number_header,
),
week_number: cn(
'text-[0.8rem] select-none text-muted-foreground',
defaultClassNames.week_number,
),
day: cn(
'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none',
defaultClassNames.day,
),
range_start: cn(
'rounded-l-md bg-accent',
defaultClassNames.range_start,
),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
today: cn(
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
defaultClassNames.today,
),
outside: cn(
'text-muted-foreground aria-selected:text-muted-foreground',
defaultClassNames.outside,
),
disabled: cn(
'text-muted-foreground opacity-50',
defaultClassNames.disabled,
),
hidden: cn('invisible', defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === 'left') {
return (
<ChevronLeftIcon className={cn('size-4', className)} {...props} />
);
}
if (orientation === 'right') {
return (
<ChevronRightIcon
className={cn('size-4', className)}
{...props}
/>
);
}
return (
<ChevronDownIcon className={cn('size-4', className)} {...props} />
);
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className,
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };

View File

@@ -0,0 +1,264 @@
import { Button } from '@/components/ui/button';
import { cn } from '@/utils/cn';
import Autoplay from 'embla-carousel-autoplay';
import useEmblaCarousel from 'embla-carousel-react';
import type { UseEmblaCarouselType } from 'embla-carousel-react';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import * as React from 'react';
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
interface 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,
plugins: [
Autoplay({
delay: 2000,
}),
],
}}
>
<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' ? '' : '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-10 w-10 rounded-full hover:scale-100 hover:translate-y-[-50%] transition-all duration-200',
orientation === 'horizontal'
? 'left-6 top-1/2 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft 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-10 w-10 rounded-full hover:scale-100 hover:translate-y-[-50%] transition-all duration-200',
orientation === 'horizontal'
? 'right-6 top-1/2 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight 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,58 @@
import { cn } from '@/utils/cn';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import * as React from 'react';
export const DumpCheckbox = ({ checked }: { checked: boolean }) => {
return (
<div
className={cn(
'block h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
checked && 'bg-primary text-primary-foreground',
)}
>
<div className="flex items-center justify-center text-current">
{checked && <Check className="h-4 w-4" />}
</div>
</div>
);
};
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'block h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn('flex items-center justify-center text-current')}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
const CheckboxInput = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<label
className={cn(
'flex min-h-10 cursor-pointer select-none gap-4 rounded-md border border-border px-3 py-[0.5rem]',
className,
)}
>
<Checkbox ref={ref} {...props} className="relative top-0.5" />
<div className="font-medium leading-5">{props.children}</div>
</label>
));
CheckboxInput.displayName = 'CheckboxInput';
export { Checkbox, CheckboxInput };

View File

@@ -0,0 +1,147 @@
import { Badge } from '@/components/ui/badge';
import { Command, CommandInput, CommandItem } from '@/components/ui/command';
import { cn } from '@/utils/cn';
import { ChevronsUpDownIcon } from 'lucide-react';
import VirtualList from 'rc-virtual-list';
import * as React from 'react';
import { useOnClickOutside } from 'usehooks-ts';
import { Button, type ButtonProps } from './button';
import { Checkbox, DumpCheckbox } from './checkbox';
import {
Popover,
PopoverContent,
PopoverPortal,
PopoverTrigger,
} from './popover';
type IValue = any;
type IItem = Record<'value' | 'label', IValue>;
interface ComboboxAdvancedProps {
value: IValue[];
onChange: (value: IValue[]) => void;
items: IItem[];
placeholder: string;
className?: string;
size?: ButtonProps['size'];
}
export function ComboboxAdvanced({
items,
value,
onChange,
placeholder,
className,
size,
}: ComboboxAdvancedProps) {
const [open, setOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState('');
const ref = React.useRef<HTMLDivElement>(null);
useOnClickOutside(ref as React.RefObject<HTMLElement>, () => setOpen(false));
const selectables = items
.filter((item) => !value.find((s) => s === item.value))
.filter(
(item) =>
(typeof item.label === 'string' &&
item.label.toLowerCase().includes(inputValue.toLowerCase())) ||
(typeof item.value === 'string' &&
item.value.toLowerCase().includes(inputValue.toLowerCase())),
);
const renderItem = (item: IItem) => {
const checked = !!value.find((s) => s === item.value);
return (
<CommandItem
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={() => {
setInputValue('');
onChange(
value.includes(item.value)
? value.filter((s) => s !== item.value)
: [...value, item.value],
);
}}
className={'flex cursor-pointer items-center gap-2'}
>
<DumpCheckbox checked={checked} />
{item?.label ?? item?.value}
</CommandItem>
);
};
const data = React.useMemo(() => {
return [
...(inputValue === ''
? []
: [
{
value: inputValue,
label: `Pick '${inputValue}'`,
},
]),
...value.map((val) => {
const item = items.find((item) => item.value === val);
return item
? {
value: val,
label: item.label,
}
: {
value: val,
label: val,
};
}),
...selectables,
].filter((item) => item.value);
}, [inputValue, selectables, items]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant={'outline'}
onClick={() => setOpen((prev) => !prev)}
className={className}
size={size}
autoHeight
>
<div className="flex w-full flex-wrap gap-1">
{value.length === 0 && placeholder}
{value.map((value) => {
const item = items.find((item) => item.value === value) ?? {
value,
label: value,
};
return <Badge key={String(item.value)}>{item.label}</Badge>;
})}
</div>
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent className="w-full max-w-md p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search"
value={inputValue}
onValueChange={setInputValue}
/>
<VirtualList
height={Math.min(items.length * 32, 300)}
data={data}
itemHeight={32}
itemKey="value"
>
{renderItem}
</VirtualList>
</Command>
</PopoverContent>
</PopoverPortal>
</Popover>
);
}

View File

@@ -0,0 +1,223 @@
import type { ButtonProps } from '@/components/ui/button';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { useNumber } from '@/hooks/use-numer-formatter';
import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { PopoverPortal } from '@radix-ui/react-popover';
import { CheckIcon, ChevronsUpDown, GanttChartIcon } from 'lucide-react';
import VirtualList from 'rc-virtual-list';
import * as React from 'react';
import { EventIcon } from '../events/event-icon';
/**
* Type-safe ComboboxEvents component that supports both single and multiple selection.
*
* @example
* // Single selection mode (default)
* <ComboboxEvents<string>
* items={events}
* value={selectedEvent}
* onChange={(event: string) => setSelectedEvent(event)}
* placeholder="Select an event"
* />
*
* @example
* // Multiple selection mode
* <ComboboxEvents<string, true>
* items={events}
* value={selectedEvents}
* onChange={(events: string[]) => setSelectedEvents(events)}
* placeholder="Select events"
* multiple={true}
* />
*/
export interface ComboboxProps<T, TMultiple extends boolean = false> {
placeholder: string;
items: RouterOutputs['chart']['events'];
value: TMultiple extends true ? T[] : T | null | undefined;
onChange: TMultiple extends true ? (value: T[]) => void : (value: T) => void;
className?: string;
searchable?: boolean;
size?: ButtonProps['size'];
label?: string;
align?: 'start' | 'end' | 'center';
portal?: boolean;
error?: string;
disabled?: boolean;
multiple?: TMultiple;
maxDisplayItems?: number;
}
export function ComboboxEvents<
T extends string,
TMultiple extends boolean = false,
>({
placeholder,
value,
onChange,
className,
searchable,
size,
align = 'start',
portal,
error,
disabled,
items,
multiple = false as TMultiple,
maxDisplayItems = 2,
}: ComboboxProps<T, TMultiple>) {
const number = useNumber();
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
const selectedValues = React.useMemo((): T[] => {
if (multiple) {
return Array.isArray(value) ? (value as T[]) : value ? [value as T] : [];
}
return value ? [value as T] : [];
}, [value, multiple]);
function find(value: string) {
return items.find(
(item) => item.name.toLowerCase() === value.toLowerCase(),
);
}
const current =
selectedValues.length > 0 && selectedValues[0]
? find(selectedValues[0])
: null;
const handleSelection = (selectedValue: string) => {
if (multiple) {
const currentValues = selectedValues;
const newValues = currentValues.includes(selectedValue as T)
? currentValues.filter((v) => v !== selectedValue)
: [...currentValues, selectedValue as T];
onChange(newValues as any);
} else {
onChange(selectedValue as any);
setOpen(false);
}
};
const renderTriggerContent = () => {
if (selectedValues.length === 0) {
return placeholder;
}
const firstValue = selectedValues[0];
const item = firstValue ? find(firstValue) : null;
let label = item?.name || firstValue;
if (multiple && selectedValues.length > 1) {
label += ` +${selectedValues.length - 1}`;
}
return label;
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
disabled={disabled}
size={size}
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
'justify-between',
!!error && 'border-destructive',
className,
)}
>
<div className="flex min-w-0 items-center">
{current?.meta ? (
<EventIcon
name={current.name}
meta={current.meta}
size="xs"
className="mr-2 shrink-0"
/>
) : (
<GanttChartIcon size={16} className="mr-2 shrink-0" />
)}
<span className="overflow-hidden text-ellipsis whitespace-nowrap">
{renderTriggerContent()}
</span>
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
className="w-full max-w-[33em] max-sm:max-w-[100vw] p-0"
align={align}
portal={portal}
>
<Command shouldFilter={false}>
{searchable === true && (
<CommandInput
placeholder="Search event..."
value={search}
onValueChange={setSearch}
/>
)}
<CommandEmpty>Nothing selected</CommandEmpty>
<VirtualList
height={400}
data={items.filter((item) => {
if (search === '') return true;
return item.name.toLowerCase().includes(search.toLowerCase());
})}
itemHeight={32}
itemKey="value"
className="w-[33em] max-sm:max-w-[100vw]"
>
{(item) => {
return (
<CommandItem
className={cn(
'p-4 py-2.5 gap-4',
selectedValues.includes(item.name as T) && 'bg-accent',
)}
key={item.name}
value={item.name}
onSelect={(currentValue) => {
handleSelection(item.name);
}}
>
{selectedValues.includes(item.name as T) ? (
<CheckIcon className="h-4 w-4 flex-shrink-0" />
) : (
<EventIcon name={item.name} meta={item.meta} size="sm" />
)}
<span className="font-medium flex-1 truncate">
{item.name === '*' ? 'Any events' : item.name}
</span>
<span className="text-muted-foreground font-mono font-medium">
{number.short(item.count)}
</span>
</CommandItem>
);
}}
</VirtualList>
</Command>
</PopoverContent>
</PopoverPortal>
</Popover>
);
}

View File

@@ -0,0 +1,165 @@
import type { ButtonProps } from '@/components/ui/button';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/utils/cn';
import { PopoverPortal } from '@radix-ui/react-popover';
import type { LucideIcon } from 'lucide-react';
import { Check, ChevronsUpDown } from 'lucide-react';
import VirtualList from 'rc-virtual-list';
import * as React from 'react';
export interface ComboboxProps<T> {
placeholder: string;
items: {
value: T;
label: string;
disabled?: boolean;
}[];
value: T | null | undefined;
onChange: (value: T) => void;
children?: React.ReactNode;
onCreate?: (value: T) => void;
className?: string;
searchable?: boolean;
icon?: LucideIcon;
size?: ButtonProps['size'];
label?: string;
align?: 'start' | 'end' | 'center';
portal?: boolean;
error?: string;
disabled?: boolean;
}
export type ExtendedComboboxProps<T> = Omit<
ComboboxProps<T>,
'items' | 'placeholder'
> & {
placeholder?: string;
};
export function Combobox<T extends string>({
placeholder,
items,
value,
onChange,
children,
onCreate,
className,
searchable,
icon: Icon,
size,
align = 'start',
portal,
error,
disabled,
}: ComboboxProps<T>) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
function find(value: string) {
return items.find(
(item) => item.value.toLowerCase() === value.toLowerCase(),
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{children ?? (
<Button
disabled={disabled}
size={size}
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
'justify-between',
!!error && 'border-destructive',
className,
)}
>
<div className="flex min-w-0 items-center">
{Icon ? <Icon size={16} className="mr-2 shrink-0" /> : null}
<span className="overflow-hidden text-ellipsis whitespace-nowrap">
{value ? (find(value)?.label ?? 'No match') : placeholder}
</span>
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
)}
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
className="w-full max-w-md p-0"
align={align}
portal={portal}
>
<Command shouldFilter={false}>
{searchable === true && (
<CommandInput
placeholder="Search item..."
value={search}
onValueChange={setSearch}
/>
)}
{typeof onCreate === 'function' && search ? (
<CommandEmpty className="p-2">
<Button
onClick={() => {
onCreate(search as T);
setSearch('');
setOpen(false);
}}
>
Create &quot;{search}&quot;
</Button>
</CommandEmpty>
) : (
<CommandEmpty>Nothing selected</CommandEmpty>
)}
<VirtualList
height={Math.min(items.length * 32, 300)}
data={items.filter((item) => {
if (search === '') return true;
return item.label.toLowerCase().includes(search.toLowerCase());
})}
itemHeight={32}
itemKey="value"
className="min-w-60"
>
{(item) => (
<CommandItem
key={item.value}
value={item.value}
onSelect={(currentValue) => {
const value = find(currentValue)?.value ?? currentValue;
onChange(value as T);
setOpen(false);
}}
{...(item.disabled && { disabled: true })}
>
<Check
className={cn(
'mr-2 h-4 w-4 flex-shrink-0',
value === item.value ? 'opacity-100' : 'opacity-0',
)}
/>
{item.label}
</CommandItem>
)}
</VirtualList>
</Command>
</PopoverContent>
</PopoverPortal>
</Popover>
);
}

View File

@@ -0,0 +1,184 @@
'use client';
import { Command as CommandPrimitive } from 'cmdk';
import { SearchIcon } from 'lucide-react';
import type * as React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = 'Command Palette',
description = 'Search for a command to run...',
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn('overflow-hidden p-0', className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto',
className,
)}
{...props}
/>
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="command-shortcut"
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,104 @@
'use client';
import type { Column } from '@tanstack/react-table';
import {
ChevronDown,
ChevronDownIcon,
ChevronUp,
ChevronUpIcon,
ChevronsUpDown,
ChevronsUpDownIcon,
EyeOff,
EyeOffIcon,
X,
XIcon,
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
interface DataTableColumnHeaderProps<TData, TValue>
extends React.ComponentProps<typeof DropdownMenuTrigger> {
column: Column<TData, TValue>;
title: string;
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
...props
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort() && !column.getCanHide()) {
return <div className={cn(className)}>{title}</div>;
}
return (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
'text-[10px] uppercase -ml-1.5 flex h-8 items-center gap-1.5 rounded-md px-2 py-1.5 hover:bg-accent focus:outline-none focus:ring-1 focus:ring-ring data-[state=open]:bg-accent [&_svg]:size-3 [&_svg]:shrink-0 [&_svg]:text-muted-foreground',
className,
)}
{...props}
>
{title}
{column.getCanSort() &&
(column.getIsSorted() === 'desc' ? (
<ChevronDownIcon />
) : column.getIsSorted() === 'asc' ? (
<ChevronUpIcon />
) : (
<ChevronsUpDownIcon />
))}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-28">
{column.getCanSort() && (
<>
<DropdownMenuCheckboxItem
className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
checked={column.getIsSorted() === 'asc'}
onClick={() => column.toggleSorting(false)}
>
<ChevronUpIcon className="size-4" />
Asc
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
checked={column.getIsSorted() === 'desc'}
onClick={() => column.toggleSorting(true)}
>
<ChevronDownIcon className="size-4" />
Desc
</DropdownMenuCheckboxItem>
{column.getIsSorted() && (
<DropdownMenuItem
className="pl-2 [&_svg]:text-muted-foreground"
onClick={() => column.clearSorting()}
>
<XIcon className="size-4" />
Reset
</DropdownMenuItem>
)}
</>
)}
{column.getCanHide() && (
<DropdownMenuCheckboxItem
className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
checked={!column.getIsVisible()}
onClick={() => column.toggleVisibility(false)}
>
<EyeOffIcon className="size-4" />
Hide
</DropdownMenuCheckboxItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,82 @@
export type DataTableConfig = typeof dataTableConfig;
export const dataTableConfig = {
textOperators: [
{ label: 'Contains', value: 'iLike' as const },
{ label: 'Does not contain', value: 'notILike' as const },
{ label: 'Is', value: 'eq' as const },
{ label: 'Is not', value: 'ne' as const },
{ label: 'Is empty', value: 'isEmpty' as const },
{ label: 'Is not empty', value: 'isNotEmpty' as const },
],
numericOperators: [
{ label: 'Is', value: 'eq' as const },
{ label: 'Is not', value: 'ne' as const },
{ label: 'Is less than', value: 'lt' as const },
{ label: 'Is less than or equal to', value: 'lte' as const },
{ label: 'Is greater than', value: 'gt' as const },
{ label: 'Is greater than or equal to', value: 'gte' as const },
{ label: 'Is between', value: 'isBetween' as const },
{ label: 'Is empty', value: 'isEmpty' as const },
{ label: 'Is not empty', value: 'isNotEmpty' as const },
],
dateOperators: [
{ label: 'Is', value: 'eq' as const },
{ label: 'Is not', value: 'ne' as const },
{ label: 'Is before', value: 'lt' as const },
{ label: 'Is after', value: 'gt' as const },
{ label: 'Is on or before', value: 'lte' as const },
{ label: 'Is on or after', value: 'gte' as const },
{ label: 'Is between', value: 'isBetween' as const },
{ label: 'Is relative to today', value: 'isRelativeToToday' as const },
{ label: 'Is empty', value: 'isEmpty' as const },
{ label: 'Is not empty', value: 'isNotEmpty' as const },
],
selectOperators: [
{ label: 'Is', value: 'eq' as const },
{ label: 'Is not', value: 'ne' as const },
{ label: 'Is empty', value: 'isEmpty' as const },
{ label: 'Is not empty', value: 'isNotEmpty' as const },
],
multiSelectOperators: [
{ label: 'Has any of', value: 'inArray' as const },
{ label: 'Has none of', value: 'notInArray' as const },
{ label: 'Is empty', value: 'isEmpty' as const },
{ label: 'Is not empty', value: 'isNotEmpty' as const },
],
booleanOperators: [
{ label: 'Is', value: 'eq' as const },
{ label: 'Is not', value: 'ne' as const },
],
sortOrders: [
{ label: 'Asc', value: 'asc' as const },
{ label: 'Desc', value: 'desc' as const },
],
filterVariants: [
'text',
'number',
'range',
'date',
'dateRange',
'boolean',
'select',
'multiSelect',
] as const,
operators: [
'iLike',
'notILike',
'eq',
'ne',
'inArray',
'notInArray',
'isEmpty',
'isNotEmpty',
'lt',
'lte',
'gt',
'gte',
'isBetween',
'isRelativeToToday',
] as const,
joinOperators: ['and', 'or'] as const,
};

View File

@@ -0,0 +1,224 @@
'use client';
import type { Column } from '@tanstack/react-table';
import { CalendarIcon, XCircle, XCircleIcon } from 'lucide-react';
import * as React from 'react';
import type { 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 { Separator } from '@/components/ui/separator';
import { formatDate } from '@/utils/date';
type DateSelection = Date[] | DateRange;
function getIsDateRange(value: DateSelection): value is DateRange {
return value && typeof value === 'object' && !Array.isArray(value);
}
function parseAsDate(timestamp: number | string | undefined): Date | undefined {
if (!timestamp) return undefined;
const numericTimestamp =
typeof timestamp === 'string' ? Number(timestamp) : timestamp;
const date = new Date(numericTimestamp);
return !Number.isNaN(date.getTime()) ? date : undefined;
}
function parseColumnFilterValue(value: unknown) {
if (value === null || value === undefined) {
return [];
}
if (Array.isArray(value)) {
return value.map((item) => {
if (typeof item === 'number' || typeof item === 'string') {
return item;
}
return undefined;
});
}
if (typeof value === 'string' || typeof value === 'number') {
return [value];
}
return [];
}
interface DataTableDateFilterProps<TData> {
column: Column<TData, unknown>;
title?: string;
multiple?: boolean;
}
export function DataTableDateFilter<TData>({
column,
title,
multiple,
}: DataTableDateFilterProps<TData>) {
const columnFilterValue = column.getFilterValue();
const selectedDates = React.useMemo<DateSelection>(() => {
if (!columnFilterValue) {
return multiple ? { from: undefined, to: undefined } : [];
}
if (multiple) {
const timestamps = parseColumnFilterValue(columnFilterValue);
return {
from: parseAsDate(timestamps[0]),
to: parseAsDate(timestamps[1]),
};
}
const timestamps = parseColumnFilterValue(columnFilterValue);
const date = parseAsDate(timestamps[0]);
return date ? [date] : [];
}, [columnFilterValue, multiple]);
const onSelect = React.useCallback(
(date: Date | DateRange | undefined) => {
if (!date) {
column.setFilterValue(undefined);
return;
}
if (multiple && !('getTime' in date)) {
const from = date.from?.getTime();
const to = date.to?.getTime();
column.setFilterValue(from || to ? [from, to] : undefined);
} else if (!multiple && 'getTime' in date) {
column.setFilterValue(date.getTime());
}
},
[column, multiple],
);
const onReset = React.useCallback(
(event: React.MouseEvent | React.KeyboardEvent) => {
event.stopPropagation();
column.setFilterValue(undefined);
},
[column],
);
const hasValue = React.useMemo(() => {
if (multiple) {
if (!getIsDateRange(selectedDates)) return false;
return selectedDates.from || selectedDates.to;
}
if (!Array.isArray(selectedDates)) return false;
return selectedDates.length > 0;
}, [multiple, selectedDates]);
const formatDateRange = React.useCallback((range: DateRange) => {
if (!range.from && !range.to) return '';
if (range.from && range.to) {
return `${formatDate(range.from)} - ${formatDate(range.to)}`;
}
return formatDate(range.from ?? range.to ?? new Date());
}, []);
const label = React.useMemo(() => {
if (multiple) {
if (!getIsDateRange(selectedDates)) return null;
const hasSelectedDates = selectedDates.from || selectedDates.to;
const dateText = hasSelectedDates
? formatDateRange(selectedDates)
: 'Select date range';
return (
<span className="flex items-center gap-2">
<span>{title}</span>
{hasSelectedDates && (
<>
<Separator
orientation="vertical"
className="mx-0.5 data-[orientation=vertical]:h-4"
/>
<span>{dateText}</span>
</>
)}
</span>
);
}
if (getIsDateRange(selectedDates)) return null;
const hasSelectedDate = selectedDates.length > 0;
const dateText = hasSelectedDate
? formatDate(selectedDates[0])
: 'Select date';
return (
<span className="flex items-center gap-2">
<span>{title}</span>
{hasSelectedDate && (
<>
<Separator
orientation="vertical"
className="mx-0.5 data-[orientation=vertical]:h-4"
/>
<span>{dateText}</span>
</>
)}
</span>
);
}, [selectedDates, multiple, formatDateRange, title]);
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="border-dashed">
{hasValue ? (
<div
onKeyDown={onReset}
role="button"
aria-label={`Clear ${title} filter`}
tabIndex={0}
onClick={onReset}
className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<XCircleIcon className="size-4 mr-2" />
</div>
) : (
<CalendarIcon className="size-4 mr-2" />
)}
{label}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto p-0 rounded-lg overflow-hidden"
align="start"
>
{multiple ? (
<Calendar
initialFocus
mode="range"
selected={
getIsDateRange(selectedDates)
? selectedDates
: { from: undefined, to: undefined }
}
onSelect={onSelect}
/>
) : (
<Calendar
initialFocus
mode="single"
selected={
!getIsDateRange(selectedDates) ? selectedDates[0] : undefined
}
onSelect={onSelect}
/>
)}
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,193 @@
'use client';
import type { Column } from '@tanstack/react-table';
import {
Check,
CheckIcon,
PlusCircle,
PlusCircleIcon,
XCircle,
XCircleIcon,
} from 'lucide-react';
import * as React from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
import type { Option } from '@/types/data-table';
interface DataTableFacetedFilterProps<TData, TValue> {
column?: Column<TData, TValue>;
title?: string;
options: Option[];
multiple?: boolean;
}
export function DataTableFacetedFilter<TData, TValue>({
column,
title,
options,
multiple,
}: DataTableFacetedFilterProps<TData, TValue>) {
const [open, setOpen] = React.useState(false);
const columnFilterValue = column?.getFilterValue();
const selectedValues = new Set(
Array.isArray(columnFilterValue) ? columnFilterValue : [],
);
const onItemSelect = React.useCallback(
(option: Option, isSelected: boolean) => {
if (!column) return;
if (multiple) {
const newSelectedValues = new Set(selectedValues);
if (isSelected) {
newSelectedValues.delete(option.value);
} else {
newSelectedValues.add(option.value);
}
const filterValues = Array.from(newSelectedValues);
column.setFilterValue(filterValues.length ? filterValues : undefined);
} else {
column.setFilterValue(isSelected ? undefined : [option.value]);
setOpen(false);
}
},
[column, multiple, selectedValues],
);
const onReset = React.useCallback(
(event?: React.MouseEvent | React.KeyboardEvent) => {
event?.stopPropagation();
column?.setFilterValue(undefined);
},
[column],
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="border-dashed">
{selectedValues?.size > 0 ? (
<div
role="button"
aria-label={`Clear ${title} filter`}
tabIndex={0}
onClick={onReset}
onKeyDown={onReset}
className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<XCircleIcon className="size-4 mr-2" />
</div>
) : (
<PlusCircleIcon className="size-4 mr-2" />
)}
{title}
{selectedValues?.size > 0 && (
<>
<Separator
orientation="vertical"
className="mx-0.5 data-[orientation=vertical]:h-4"
/>
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal lg:hidden"
>
{selectedValues.size}
</Badge>
<div className="hidden items-center gap-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal"
>
{selectedValues.size} selected
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant="secondary"
key={option.value}
className="rounded-sm px-1 font-normal"
>
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[12.5rem] p-0" align="start">
<Command>
<CommandInput placeholder={title} />
<CommandList className="max-h-full">
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup className="max-h-[18.75rem] overflow-y-auto overflow-x-hidden">
{options.map((option) => {
const isSelected = selectedValues.has(option.value);
return (
<CommandItem
key={option.value}
onSelect={() => onItemSelect(option, isSelected)}
>
<div
className={cn(
'flex size-4 items-center justify-center rounded-sm border border-primary',
isSelected
? 'bg-primary'
: 'opacity-50 [&_svg]:invisible',
)}
>
<CheckIcon className="size-4" />
</div>
{option.icon && <option.icon className="size-4 mr-2" />}
<span className="truncate">{option.label}</span>
{option.count && (
<span className="ml-auto font-mono text-xs">
{option.count}
</span>
)}
</CommandItem>
);
})}
</CommandGroup>
{selectedValues.size > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => onReset()}
className="justify-center text-center"
>
Clear filters
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,77 @@
import type { Column, ColumnDef, Row } from '@tanstack/react-table';
import { MoreHorizontalIcon } from 'lucide-react';
import { Button } from '../button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '../dropdown-menu';
import { DataTableColumnHeader } from './data-table-column-header';
export function createHeaderColumn<TData>(
title: string,
): ColumnDef<TData>['header'] {
return ({ column }) => (
<DataTableColumnHeader column={column} title={title} />
);
}
export function createActionColumn<TData>(
Component: ({ row }: { row: Row<TData> }) => React.ReactNode,
): ColumnDef<TData> {
return {
id: 'actions',
header: '',
enablePinning: true,
meta: {
pinned: 'right',
},
size: 40,
cell: ({ row }) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button icon={MoreHorizontalIcon} size="icon" variant={'ghost'} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
<Component row={row} />
</DropdownMenuContent>
</DropdownMenu>
);
},
};
}
export function getCommonPinningStyles<TData>({
column,
}: {
column: Column<TData>;
}): React.CSSProperties {
const pinnedColumnWidth = 40;
const isPinned = column.getIsPinned();
const isLastLeftPinnedColumn =
isPinned === 'left' && column.getIsLastColumn('left');
const isFirstRightPinnedColumn =
isPinned === 'right' && column.getIsFirstColumn('right');
return {
boxShadow: isLastLeftPinnedColumn
? '-4px 0 4px -4px var(--border) inset'
: isFirstRightPinnedColumn
? '4px 0 4px -4px var(--border) inset'
: undefined,
textAlign: isPinned && isFirstRightPinnedColumn ? 'right' : undefined,
left: isPinned === 'left' ? `${column.getStart('left')}px` : undefined,
right: isPinned === 'right' ? `${column.getAfter('right')}px` : undefined,
opacity: isPinned ? 0.97 : 1,
position: isPinned ? 'sticky' : 'relative',
zIndex: isPinned ? 1 : 0,
// Force fixed width for pinned columns, let others auto-size
width: isPinned ? `${pinnedColumnWidth}px` : 'auto',
minWidth: isPinned ? `${pinnedColumnWidth}px` : undefined,
maxWidth: isPinned ? `${pinnedColumnWidth}px` : undefined,
flexShrink: isPinned ? 0 : undefined,
flexGrow: isPinned ? 0 : undefined,
padding: isPinned ? 4 : undefined,
};
}

View File

@@ -0,0 +1,34 @@
import type {
ColumnDef,
PaginationState,
VisibilityState,
} from '@tanstack/react-table';
import { parseAsInteger, useQueryState } from 'nuqs';
import { useState } from 'react';
export const useDataTablePagination = (pageSize = 10) => {
const [page, setPage] = useQueryState(
'page',
parseAsInteger.withDefault(1).withOptions({
clearOnDefault: true,
history: 'push',
}),
);
const state: PaginationState = {
pageIndex: page - 1,
pageSize: pageSize,
};
return { page, setPage, state };
};
export const useDataTableColumnVisibility = <TData,>(
columns: ColumnDef<TData>[],
) => {
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
columns.reduce((acc, column) => {
acc[column.id!] = column.meta?.hidden ?? false;
return acc;
}, {} as VisibilityState),
);
return { columnVisibility, setColumnVisibility };
};

View File

@@ -0,0 +1,99 @@
import { createParser } from 'nuqs/server';
import { z } from 'zod';
import { dataTableConfig } from '@/components/ui/data-table/data-table-config';
import type {
ExtendedColumnFilter,
ExtendedColumnSort,
} from '@/types/data-table';
const sortingItemSchema = z.object({
id: z.string(),
desc: z.boolean(),
});
export const getSortingStateParser = <TData>(
columnIds?: string[] | Set<string>,
) => {
const validKeys = columnIds
? columnIds instanceof Set
? columnIds
: new Set(columnIds)
: null;
return createParser({
parse: (value) => {
try {
const parsed = JSON.parse(value);
const result = z.array(sortingItemSchema).safeParse(parsed);
if (!result.success) return null;
if (validKeys && result.data.some((item) => !validKeys.has(item.id))) {
return null;
}
return result.data as ExtendedColumnSort<TData>[];
} catch {
return null;
}
},
serialize: (value) => JSON.stringify(value),
eq: (a, b) =>
a.length === b.length &&
a.every(
(item, index) =>
item.id === b[index]?.id && item.desc === b[index]?.desc,
),
});
};
const filterItemSchema = z.object({
id: z.string(),
value: z.union([z.string(), z.array(z.string())]),
variant: z.enum(dataTableConfig.filterVariants),
operator: z.enum(dataTableConfig.operators),
filterId: z.string(),
});
export type FilterItemSchema = z.infer<typeof filterItemSchema>;
export const getFiltersStateParser = <TData>(
columnIds?: string[] | Set<string>,
) => {
const validKeys = columnIds
? columnIds instanceof Set
? columnIds
: new Set(columnIds)
: null;
return createParser({
parse: (value) => {
try {
const parsed = JSON.parse(value);
const result = z.array(filterItemSchema).safeParse(parsed);
if (!result.success) return null;
if (validKeys && result.data.some((item) => !validKeys.has(item.id))) {
return null;
}
return result.data as ExtendedColumnFilter<TData>[];
} catch {
return null;
}
},
serialize: (value) => JSON.stringify(value),
eq: (a, b) =>
a.length === b.length &&
a.every(
(filter, index) =>
filter.id === b[index]?.id &&
filter.value === b[index]?.value &&
filter.variant === b[index]?.variant &&
filter.operator === b[index]?.operator,
),
});
};

View File

@@ -0,0 +1,239 @@
'use client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
import { Slider } from '@/components/ui/slider';
import { cn } from '@/lib/utils';
import type { Column } from '@tanstack/react-table';
import { PlusCircle, XCircle } from 'lucide-react';
import * as React from 'react';
interface Range {
min: number;
max: number;
}
type RangeValue = [number, number];
function getIsValidRange(value: unknown): value is RangeValue {
return (
Array.isArray(value) &&
value.length === 2 &&
typeof value[0] === 'number' &&
typeof value[1] === 'number'
);
}
interface DataTableSliderFilterProps<TData> {
column: Column<TData, unknown>;
title?: string;
}
export function DataTableSliderFilter<TData>({
column,
title,
}: DataTableSliderFilterProps<TData>) {
const id = React.useId();
const columnFilterValue = getIsValidRange(column.getFilterValue())
? (column.getFilterValue() as RangeValue)
: undefined;
const defaultRange = column.columnDef.meta?.range;
const unit = column.columnDef.meta?.unit;
const { min, max, step } = React.useMemo<Range & { step: number }>(() => {
let minValue = 0;
let maxValue = 100;
if (defaultRange && getIsValidRange(defaultRange)) {
[minValue, maxValue] = defaultRange;
} else {
const values = column.getFacetedMinMaxValues();
if (values && Array.isArray(values) && values.length === 2) {
const [facetMinValue, facetMaxValue] = values;
if (
typeof facetMinValue === 'number' &&
typeof facetMaxValue === 'number'
) {
minValue = facetMinValue;
maxValue = facetMaxValue;
}
}
}
const rangeSize = maxValue - minValue;
const step =
rangeSize <= 20
? 1
: rangeSize <= 100
? Math.ceil(rangeSize / 20)
: Math.ceil(rangeSize / 50);
return { min: minValue, max: maxValue, step };
}, [column, defaultRange]);
const range = React.useMemo((): RangeValue => {
return columnFilterValue ?? [min, max];
}, [columnFilterValue, min, max]);
const formatValue = React.useCallback((value: number) => {
return value.toLocaleString(undefined, { maximumFractionDigits: 0 });
}, []);
const onFromInputChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const numValue = Number(event.target.value);
if (!Number.isNaN(numValue) && numValue >= min && numValue <= range[1]) {
column.setFilterValue([numValue, range[1]]);
}
},
[column, min, range],
);
const onToInputChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const numValue = Number(event.target.value);
if (!Number.isNaN(numValue) && numValue <= max && numValue >= range[0]) {
column.setFilterValue([range[0], numValue]);
}
},
[column, max, range],
);
const onSliderValueChange = React.useCallback(
(value: RangeValue) => {
if (Array.isArray(value) && value.length === 2) {
column.setFilterValue(value);
}
},
[column],
);
const onReset = React.useCallback(
(event: React.MouseEvent) => {
if (event.target instanceof HTMLDivElement) {
event.stopPropagation();
}
column.setFilterValue(undefined);
},
[column],
);
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="border-dashed">
{columnFilterValue ? (
<div
role="button"
aria-label={`Clear ${title} filter`}
tabIndex={0}
className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
onClick={onReset}
>
<XCircle />
</div>
) : (
<PlusCircle />
)}
<span>{title}</span>
{columnFilterValue ? (
<>
<Separator
orientation="vertical"
className="mx-0.5 data-[orientation=vertical]:h-4"
/>
{formatValue(columnFilterValue[0])} -{' '}
{formatValue(columnFilterValue[1])}
{unit ? ` ${unit}` : ''}
</>
) : null}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="flex w-auto flex-col gap-4">
<div className="flex flex-col gap-3">
<p className="font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{title}
</p>
<div className="flex items-center gap-4">
<Label htmlFor={`${id}-from`} className="sr-only">
From
</Label>
<div className="relative">
<Input
id={`${id}-from`}
type="number"
aria-valuemin={min}
aria-valuemax={max}
inputMode="numeric"
pattern="[0-9]*"
placeholder={min.toString()}
min={min}
max={max}
value={range[0]?.toString()}
onChange={onFromInputChange}
className={cn('h-8 w-24', unit && 'pr-8')}
/>
{unit && (
<span className="absolute top-0 right-0 bottom-0 flex items-center rounded-r-md bg-accent px-2 text-muted-foreground text-sm">
{unit}
</span>
)}
</div>
<Label htmlFor={`${id}-to`} className="sr-only">
to
</Label>
<div className="relative">
<Input
id={`${id}-to`}
type="number"
aria-valuemin={min}
aria-valuemax={max}
inputMode="numeric"
pattern="[0-9]*"
placeholder={max.toString()}
min={min}
max={max}
value={range[1]?.toString()}
onChange={onToInputChange}
className={cn('h-8 w-24', unit && 'pr-8')}
/>
{unit && (
<span className="absolute top-0 right-0 bottom-0 flex items-center rounded-r-md bg-accent px-2 text-muted-foreground text-sm">
{unit}
</span>
)}
</div>
</div>
<Label htmlFor={`${id}-slider`} className="sr-only">
{title} slider
</Label>
<Slider
id={`${id}-slider`}
min={min}
max={max}
step={step}
value={range}
onValueChange={onSliderValueChange}
/>
</div>
<Button
aria-label={`Clear ${title} filter`}
variant="outline"
size="sm"
onClick={onReset}
>
Clear
</Button>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,237 @@
'use client';
import type { Column, Table } from '@tanstack/react-table';
import { SearchIcon, X, XIcon } from 'lucide-react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { DataTableDateFilter } from '@/components/ui/data-table/data-table-date-filter';
import { DataTableFacetedFilter } from '@/components/ui/data-table/data-table-faceted-filter';
import { DataTableSliderFilter } from '@/components/ui/data-table/data-table-slider-filter';
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
import { Input } from '@/components/ui/input';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { cn } from '@/lib/utils';
interface DataTableToolbarProps<TData> extends React.ComponentProps<'div'> {
table: Table<TData>;
globalSearchKey?: string;
globalSearchPlaceholder?: string;
}
export function DataTableToolbarContainer({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
role="toolbar"
aria-orientation="horizontal"
className={cn(
'flex flex-1 items-start justify-between gap-2 mb-2',
className,
)}
{...props}
/>
);
}
export function DataTableToolbar<TData>({
table,
children,
className,
globalSearchKey,
globalSearchPlaceholder,
...props
}: DataTableToolbarProps<TData>) {
const { search, setSearch } = useSearchQueryState({
searchKey: globalSearchKey,
});
const isFiltered = table.getState().columnFilters.length > 0;
const columns = React.useMemo(
() => table.getAllColumns().filter((column) => column.getCanFilter()),
[table],
);
const onReset = React.useCallback(() => {
table.resetColumnFilters();
}, [table]);
return (
<DataTableToolbarContainer className={className} {...props}>
<div className="flex flex-1 flex-wrap items-center gap-2">
{globalSearchKey && (
<AnimatedSearchInput
placeholder={globalSearchPlaceholder ?? 'Search'}
value={search}
onChange={setSearch}
/>
)}
{columns.map((column) => (
<DataTableToolbarFilter key={column.id} column={column} />
))}
{isFiltered && (
<Button
aria-label="Reset filters"
variant="outline"
size="sm"
className="border-dashed"
onClick={onReset}
>
<XIcon className="size-4 mr-2" />
Reset
</Button>
)}
</div>
<div className="flex items-center gap-2">
{children}
<DataTableViewOptions table={table} />
</div>
</DataTableToolbarContainer>
);
}
interface DataTableToolbarFilterProps<TData> {
column: Column<TData>;
}
function DataTableToolbarFilter<TData>({
column,
}: DataTableToolbarFilterProps<TData>) {
{
const columnMeta = column.columnDef.meta;
const getTitle = React.useCallback(() => {
return columnMeta?.label ?? columnMeta?.placeholder ?? column.id;
}, [columnMeta, column]);
const onFilterRender = React.useCallback(() => {
if (!columnMeta?.variant) return null;
switch (columnMeta.variant) {
case 'text':
return (
<AnimatedSearchInput
placeholder={columnMeta.placeholder ?? columnMeta.label}
value={(column.getFilterValue() as string) ?? ''}
onChange={(value) => column.setFilterValue(value)}
/>
);
case 'number':
return (
<div className="relative">
<Input
type="number"
inputMode="numeric"
placeholder={getTitle()}
value={(column.getFilterValue() as string) ?? ''}
onChange={(event) => column.setFilterValue(event.target.value)}
className={cn('h-8 w-[120px]', columnMeta.unit && 'pr-8')}
/>
{columnMeta.unit && (
<span className="absolute top-0 right-0 bottom-0 flex items-center rounded-r-md bg-accent px-2 text-muted-foreground text-sm">
{columnMeta.unit}
</span>
)}
</div>
);
case 'range':
return <DataTableSliderFilter column={column} title={getTitle()} />;
case 'date':
case 'dateRange':
return (
<DataTableDateFilter
column={column}
title={getTitle()}
multiple={columnMeta.variant === 'dateRange'}
/>
);
case 'select':
case 'multiSelect':
return (
<DataTableFacetedFilter
column={column}
title={getTitle()}
options={columnMeta.options ?? []}
multiple={columnMeta.variant === 'multiSelect'}
/>
);
default:
return null;
}
}, [column, columnMeta]);
return onFilterRender();
}
}
interface AnimatedSearchInputProps {
placeholder?: string;
value: string;
onChange: (value: string) => void;
}
export function AnimatedSearchInput({
placeholder,
value,
onChange,
}: AnimatedSearchInputProps) {
const [isFocused, setIsFocused] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const isExpanded = isFocused || (value?.length ?? 0) > 0;
const handleClear = React.useCallback(() => {
onChange('');
// Re-focus after clearing
requestAnimationFrame(() => inputRef.current?.focus());
}, [onChange]);
return (
<div
className={cn(
'relative flex h-8 items-center rounded-md border border-input bg-background text-sm transition-[width] duration-300 ease-out',
'focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background',
isExpanded ? 'w-56 lg:w-72' : 'w-32',
)}
role="search"
aria-label={placeholder ?? 'Search'}
>
<SearchIcon className="size-4 ml-2 shrink-0" />
<Input
ref={inputRef}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={cn(
'absolute inset-0 -top-px h-8 w-full rounded-md border-0 bg-transparent pl-7 pr-7 shadow-none',
'focus-visible:ring-0 focus-visible:ring-offset-0',
'transition-opacity duration-200',
'font-medium text-[14px] truncate align-baseline',
)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
{isExpanded && value && (
<button
type="button"
aria-label="Clear search"
className="absolute right-1 flex size-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleClear();
}}
>
<X className="size-4" />
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,87 @@
'use client';
import { Button } from '@/components/ui/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 type { Table } from '@tanstack/react-table';
import { Check, ChevronsUpDown, Settings2Icon } from 'lucide-react';
import * as React from 'react';
interface DataTableViewOptionsProps<TData> {
table: Table<TData>;
}
export function DataTableViewOptions<TData>({
table,
}: DataTableViewOptionsProps<TData>) {
const columns = React.useMemo(
() =>
table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== 'undefined' && column.getCanHide(),
),
[table],
);
return (
<Popover>
<PopoverTrigger asChild>
<Button
aria-label="Toggle columns"
role="combobox"
variant="outline"
size="sm"
className="ml-auto hidden h-8 lg:flex"
>
<Settings2Icon className="size-4 mr-2" />
View
<ChevronsUpDown className="opacity-50 ml-2 size-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-0">
<Command>
<CommandInput placeholder="Search columns..." />
<CommandList>
<CommandEmpty>No columns found.</CommandEmpty>
<CommandGroup>
{columns.map((column) => (
<CommandItem
key={column.id}
onSelect={() =>
column.toggleVisibility(!column.getIsVisible())
}
>
<span className="truncate">
{typeof column.columnDef.header === 'string'
? column.columnDef.header
: (column.columnDef.meta?.label ?? column.id)}
</span>
<Check
className={cn(
'ml-auto size-4 shrink-0',
column.getIsVisible() ? 'opacity-100' : 'opacity-0',
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,138 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { FloatingPagination } from '@/components/pagination-floating';
import { Skeleton } from '@/components/skeleton';
import { cn } from '@/utils/cn';
import type { Table as ITable } from '@tanstack/react-table';
import { flexRender } from '@tanstack/react-table';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '../table';
import { getCommonPinningStyles } from './data-table-helpers';
export interface DataTableProps<TData> {
table: ITable<TData>;
className?: string;
loading?: boolean;
empty?: {
title: string;
description: string;
};
}
declare module '@tanstack/react-table' {
interface ColumnMeta<TData, TValue> {
pinned?: 'left' | 'right';
bold?: boolean;
}
}
export function DataTable<TData>({
table,
loading,
className,
empty = {
title: 'No data',
description: 'We could not find any data here yet',
},
...props
}: DataTableProps<TData>) {
return (
<div
className={cn('flex w-full flex-col gap-2.5 overflow-auto', className)}
{...props}
>
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
colSpan={header.colSpan}
style={{
...getCommonPinningStyles({
column: header.column,
}),
}}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
...getCommonPinningStyles({
column: cell.column,
}),
}}
className={cn(
cell.column.columnDef.meta?.bold && 'font-medium',
)}
>
{loading ? (
<Skeleton className="h-4 w-3/5" />
) : (
flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={table.getAllColumns().length}
className="h-24 text-center"
>
<FullPageEmptyState
title={empty.title}
description={empty.description}
/>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{table.getPageCount() > 1 && (
<>
<FloatingPagination
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
pageIndex={table.getState().pagination.pageIndex}
nextPage={table.nextPage}
previousPage={table.previousPage}
firstPage={table.firstPage}
lastPage={table.lastPage}
/>
<div className="h-20" />
</>
)}
</div>
);
}

View File

@@ -0,0 +1,227 @@
import {
type ColumnDef,
type ColumnPinningState,
type Updater,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import type {
ColumnFiltersState,
PaginationState,
} from '@tanstack/react-table';
import type { Row } from '@tanstack/react-table';
import {
type Options,
type Parser,
parseAsArrayOf,
parseAsInteger,
parseAsString,
useQueryState,
useQueryStates,
} from 'nuqs';
import React, { useMemo, useState } from 'react';
const nuqsOptions: Options = {
shallow: true,
history: 'push',
clearOnDefault: true,
};
export function useTable<TData>({
columns,
pageSize,
data,
loading,
}: {
columns: ColumnDef<TData>[];
pageSize: number;
data: TData[];
loading: boolean;
}) {
const [page, setPage] = useQueryState(
'page',
parseAsInteger.withDefault(1).withOptions(nuqsOptions),
);
const [perPage, setPerPage] = useQueryState(
'perPage',
parseAsInteger.withDefault(pageSize ?? 10).withOptions(nuqsOptions),
);
const pagination: PaginationState = {
pageIndex: page - 1,
pageSize: perPage,
};
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>({
left: [
...columns
.filter((column) => column.meta?.pinned === 'left')
.map((column) => column.id!),
],
right: columns
.filter((column) => column.meta?.pinned === 'right')
.map((column) => column.id!),
});
// Build per-key query parsers based on column metadata
const filterParsers = useMemo(() => {
return columns.reduce<
Record<string, Parser<string> | Parser<string[]> | Parser<number[]>>
>((acc, column) => {
const columnId = (column.id ?? (column as any).accessorKey)?.toString();
if (!columnId) return acc;
const variant = column.meta?.variant;
switch (variant) {
case 'text':
case 'number':
acc[columnId] = parseAsString.withDefault('');
break;
case 'select':
acc[columnId] = parseAsString.withDefault('');
break;
case 'multiSelect':
acc[columnId] = parseAsArrayOf(parseAsString).withDefault([]);
break;
case 'date':
case 'dateRange':
case 'range':
acc[columnId] = parseAsArrayOf(parseAsInteger).withDefault([]);
break;
default:
// Non-filterable or unspecified variant -> skip
break;
}
return acc;
}, {});
}, [columns]);
const [qsFilters, setQsFilters] = useQueryStates(filterParsers, nuqsOptions);
const initialColumnFilters: ColumnFiltersState = useMemo(() => {
return Object.entries(qsFilters).reduce<ColumnFiltersState>(
(filters, [key, value]) => {
if (value === null || value === undefined) return filters;
if (Array.isArray(value)) {
if (value.length > 0) filters.push({ id: key, value });
} else if (value !== '') {
filters.push({ id: key, value });
}
return filters;
},
[],
);
}, [qsFilters]);
const [columnFilters, setColumnFilters] =
useState<ColumnFiltersState>(initialColumnFilters);
// Keep table filters in sync when the URL-driven query state changes
// (e.g., back/forward navigation or external updates)
React.useEffect(() => {
setColumnFilters(initialColumnFilters);
}, [initialColumnFilters]);
const isWithinRange = (
row: Row<TData>,
columnId: string,
value: [number, number],
) => {
const cellDate = row.getValue<Date>(columnId);
if (!cellDate) return false;
const [rawStart, rawEnd] = value; // epoch ms from date inputs (local)
// Normalize to full-day local bounds to avoid timezone truncation
const startDate = new Date(rawStart ?? rawEnd);
const endDate = new Date(rawEnd ?? rawStart);
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
return false;
}
startDate.setHours(0, 0, 0, 0);
endDate.setHours(23, 59, 59, 999);
const time = new Date(cellDate).getTime();
return time >= startDate.getTime() && time <= endDate.getTime();
};
const table = useReactTable({
columns,
data: useMemo(
() =>
loading ? ([{}, {}, {}, {}, {}, {}, {}, {}, {}, {}] as TData[]) : data,
[loading, data],
),
debugTable: false,
filterFns: {
isWithinRange,
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
autoResetPageIndex: false,
onPaginationChange: (updaterOrValue: Updater<PaginationState>) => {
const nextPagination =
typeof updaterOrValue === 'function'
? updaterOrValue(pagination)
: updaterOrValue;
const nextPage = nextPagination.pageIndex + 1;
const nextPerPage = nextPagination.pageSize;
// Only write to the URL when values truly change to avoid reload loops
if (nextPage !== page) void setPage(nextPage);
if (nextPerPage !== perPage) void setPerPage(nextPerPage);
},
state: {
pagination,
columnPinning,
columnFilters: loading ? [] : columnFilters,
},
onColumnPinningChange: setColumnPinning,
onColumnFiltersChange: (updaterOrValue: Updater<ColumnFiltersState>) => {
setColumnFilters((prev) => {
const next =
typeof updaterOrValue === 'function'
? updaterOrValue(prev)
: updaterOrValue;
const updates: Record<string, string | string[] | number[] | null> = {};
const validKeys = new Set(Object.keys(filterParsers));
for (const filter of next) {
if (validKeys.has(filter.id)) {
const value = filter.value as any;
if (Array.isArray(value)) {
const cleaned = value.filter(
(v) => v !== undefined && v !== null,
);
updates[filter.id] = cleaned as any;
} else {
updates[filter.id] = value;
}
}
}
for (const prevFilter of prev) {
if (
!next.some((f) => f.id === prevFilter.id) &&
validKeys.has(prevFilter.id)
) {
updates[prevFilter.id] = null;
}
}
void setPage(1);
void setQsFilters(updates);
return next;
});
},
});
return { table, loading };
}

View File

@@ -0,0 +1,181 @@
import { FloatingPagination } from '@/components/pagination-floating';
import { Skeleton } from '@/components/skeleton';
import { cn } from '@/utils/cn';
import type { Table as ITable, Row } from '@tanstack/react-table';
import { flexRender } from '@tanstack/react-table';
import {
type VirtualItem,
useWindowVirtualizer,
} from '@tanstack/react-virtual';
import throttle from 'lodash.throttle';
import { useEffect, useRef, useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '../table';
import { DataTableColumnHeader } from './data-table-column-header';
import { getCommonPinningStyles } from './data-table-helpers';
export interface DataTableProps<TData> {
table: ITable<TData>;
className?: string;
loading?: boolean;
}
declare module '@tanstack/react-table' {
interface ColumnMeta<TData, TValue> {
pinned?: 'left' | 'right';
bold?: boolean;
}
}
export function VirtualizedDataTable<TData>({
table,
loading,
className,
...props
}: DataTableProps<TData>) {
const parentRef = useRef<HTMLDivElement>(null);
const [scrollMargin, setScrollMargin] = useState(0);
const { rows } = table.getRowModel();
const virtualizer = useWindowVirtualizer({
count: rows.length,
estimateSize: () => 60,
scrollMargin,
overscan: 10,
});
useEffect(() => {
const updateScrollMargin = throttle(() => {
if (parentRef.current) {
setScrollMargin(
parentRef.current.getBoundingClientRect().top + window.scrollY,
);
}
}, 500);
// Initial calculation
updateScrollMargin();
// Listen for scroll and resize events
// window.addEventListener('scroll', updateScrollMargin);
window.addEventListener('resize', updateScrollMargin);
return () => {
// window.removeEventListener('scroll', updateScrollMargin);
window.removeEventListener('resize', updateScrollMargin);
};
}, []);
const visibleRows = virtualizer.getVirtualItems();
const renderTableRow = (row: Row<TData>, virtualRow: VirtualItem) => {
return (
<TableRow
data-index={virtualRow.index}
// ref={virtualizer.measureElement}
className={cn('absolute top-0 left-0 w-full')}
style={{
transform: `translateY(${
virtualRow.start - virtualizer.options.scrollMargin
}px)`,
height: `${virtualRow.size}px`,
display: 'flex',
}}
key={row.id}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
...getCommonPinningStyles({ column: cell.column }),
width: cell.column.getSize(),
minWidth: cell.column.getSize(),
maxWidth: cell.column.getSize(),
display: 'flex',
alignItems: 'center',
}}
className={cn(cell.column.columnDef.meta?.bold && 'font-medium')}
>
{loading ? (
<Skeleton className="h-4 w-3/5" />
) : (
flexRender(cell.column.columnDef.cell, cell.getContext())
)}
</TableCell>
))}
</TableRow>
);
};
return (
<div
className={cn('flex w-full flex-col gap-2.5 overflow-auto', className)}
{...props}
>
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} style={{ display: 'flex' }}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
colSpan={header.colSpan}
style={{
...getCommonPinningStyles({ column: header.column }),
width: header.column.getSize(),
minWidth: header.column.getSize(),
maxWidth: header.column.getSize(),
display: 'flex',
alignItems: 'center',
}}
>
{header.isPlaceholder ? null : typeof header.column
.columnDef.header === 'function' ? (
flexRender(
header.column.columnDef.header,
header.getContext(),
)
) : (
<DataTableColumnHeader
column={header.column}
title={header.column.columnDef.header ?? ''}
/>
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{visibleRows.length ? (
visibleRows.map((row) => renderTableRow(rows[row.index]!, row))
) : (
<TableRow style={{ display: 'flex', height: '96px' }}>
<TableCell
colSpan={table.getAllColumns().length}
className="h-24 text-center flex items-center justify-center"
style={{ width: '100%' }}
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
'use client';
import { format } from 'date-fns';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import { PopoverPortal } from '@radix-ui/react-popover';
import { CalendarIcon } from 'lucide-react';
export function DateTimePicker({
value,
onChange,
}: { value: Date; onChange: (date: Date) => void }) {
function handleDateSelect(date: Date | undefined) {
if (date) {
onChange(date);
}
}
function handleTimeChange(type: 'hour' | 'minute', value: string) {
const currentDate = value || new Date();
const newDate = new Date(currentDate);
if (type === 'hour') {
const hour = Number.parseInt(value, 10);
newDate.setHours(hour);
} else if (type === 'minute') {
newDate.setMinutes(Number.parseInt(value, 10));
}
onChange(newDate);
}
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant={'outline'}
className={cn(
'w-full pl-3 text-left font-normal',
!value && 'text-muted-foreground',
)}
>
{value ? (
format(value, 'MM/dd/yyyy HH:mm')
) : (
<span>MM/DD/YYYY HH:mm</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent className="w-auto p-0">
<div className="sm:flex">
<Calendar
mode="single"
selected={value}
onSelect={handleDateSelect}
initialFocus
/>
<div className="flex flex-col sm:flex-row sm:h-[300px] divide-y sm:divide-y-0 sm:divide-x">
<ScrollArea className="w-64 sm:w-auto">
<div className="flex sm:flex-col p-2">
{Array.from({ length: 24 }, (_, i) => i)
.reverse()
.map((hour) => (
<Button
key={hour}
size="icon"
variant={
value && value.getHours() === hour
? 'default'
: 'ghost'
}
className="sm:w-full shrink-0 aspect-square"
onClick={() =>
handleTimeChange('hour', hour.toString())
}
>
{hour}
</Button>
))}
</div>
<ScrollBar orientation="horizontal" className="sm:hidden" />
</ScrollArea>
<ScrollArea className="w-64 sm:w-auto">
<div className="flex sm:flex-col p-2">
{Array.from({ length: 12 }, (_, i) => i * 5).map((minute) => (
<Button
key={minute}
size="icon"
variant={
value && value.getMinutes() === minute
? 'default'
: 'ghost'
}
className="sm:w-full shrink-0 aspect-square"
onClick={() =>
handleTimeChange('minute', minute.toString())
}
>
{minute.toString().padStart(2, '0')}
</Button>
))}
</div>
<ScrollBar orientation="horizontal" className="sm:hidden" />
</ScrollArea>
</div>
</div>
</PopoverContent>
</PopoverPortal>
</Popover>
);
}

View File

@@ -0,0 +1,146 @@
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-react';
import type * as React from 'react';
import { cn } from '@/lib/utils';
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = false,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
// Not using DialogPortal because it's breaking useRef's for some reason
return (
<div data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'max-h-screen overflow-y-auto overflow-x-hidden', // Ensure the dialog is scrollable if it exceeds the screen height
'mt-auto', // Add margin-top: auto for all screen sizes
'focus:outline-none focus:ring-0 transition-none',
'border bg-background shadow-lg sm:rounded-lg md:max-h-[90vh]',
'bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</div>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-footer"
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg leading-none font-semibold', className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -0,0 +1,244 @@
import { cn } from '@/utils/cn';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import * as React from 'react';
import { ButtonProps } from './button';
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 outline-none focus:bg-accent data-[state=open]:bg-accent',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-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-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-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>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-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',
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;
variant?: 'destructive';
}
>(({ className, inset, variant, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex min-h-8 cursor-pointer select-none items-center rounded-sm px-2 py-1.5 outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:mr-2',
inset && 'pl-8',
variant === 'destructive' &&
'text-destructive hover:text-destructive-foreground',
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(
'cursor-pointer relative flex select-none items-center rounded-sm py-1.5 pl-8 pr-2 outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:mr-2',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-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-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 outline-none 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 h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-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 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;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto font-mono text-sm tracking-widest opacity-60',
className,
)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
interface DropdownProps<Value> {
children: React.ReactNode;
label?: string;
items: {
label: string;
value: Value;
}[];
onChange?: (value: Value) => void;
}
export function DropdownMenuComposed<Value extends string>({
children,
label,
items,
onChange,
}: DropdownProps<Value>) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start">
{label && (
<>
<DropdownMenuLabel>{label}</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuGroup>
{items.map((item) => (
<DropdownMenuItem
className="cursor-pointer"
key={item.value}
onClick={() => {
onChange?.(item.value);
}}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@@ -0,0 +1,24 @@
import { cn } from '@/utils/cn';
interface GradientBackgroundProps {
children: React.ReactNode;
className?: string;
}
export function GradientBackground({
children,
className,
...props
}: GradientBackgroundProps) {
return (
<div
className={cn(
'from-def-200 rounded-md bg-gradient-to-tr to-white',
className,
)}
{...props}
>
<div className="flex flex-col gap-4 p-4">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { pushModal } from '@/modals';
import { format, isValid, parseISO } from 'date-fns';
import { CalendarIcon } from 'lucide-react';
import { type InputHTMLAttributes, useEffect, useState } from 'react';
import { WithLabel } from '../forms/input-with-label';
import { Input } from './input';
export function InputDateTime({
value,
onChange,
placeholder = 'Select date and time',
label,
...props
}: {
value: string | undefined;
onChange: (value: string) => void;
label: string;
} & InputHTMLAttributes<HTMLInputElement>) {
const [internalValue, setInternalValue] = useState(value ?? '');
useEffect(() => {
if (value !== internalValue) {
setInternalValue(value ?? '');
}
}, [value]);
// Convert string to Date for modal
const getDateFromValue = (dateString: string): Date => {
if (!dateString) return new Date();
try {
const date = parseISO(dateString);
return isValid(date) ? date : new Date();
} catch {
return new Date();
}
};
// Format date for display
const getDisplayValue = (dateString: string): string => {
if (!dateString) return '';
try {
const date = parseISO(dateString);
return isValid(date) ? format(date, 'MM/dd/yyyy HH:mm') : dateString;
} catch {
return dateString;
}
};
const handleDateTimeSelect = () => {
pushModal('DateTimePicker', {
initialDate: getDateFromValue(value || ''),
title: 'Select Date & Time',
onChange: (selectedDate: Date) => {
const isoString = selectedDate.toISOString();
setInternalValue(isoString);
onChange(isoString);
},
});
};
return (
<WithLabel label={label}>
<div
className="relative w-full cursor-pointer"
onClick={handleDateTimeSelect}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleDateTimeSelect();
}
}}
>
<Input
{...props}
value={getDisplayValue(value || '')}
placeholder={placeholder}
readOnly
className="cursor-pointer pr-10"
size="default"
/>
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none">
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
</div>
</div>
</WithLabel>
);
}

View File

@@ -0,0 +1,58 @@
import { motion } from 'framer-motion';
import { AnimatePresence } from 'framer-motion';
import { RefreshCcwIcon } from 'lucide-react';
import { type InputHTMLAttributes, useEffect, useState } from 'react';
import { Badge } from './badge';
import { Input } from './input';
export function InputEnter({
value,
onChangeValue,
...props
}: {
value: string | undefined;
onChangeValue: (value: string) => void;
} & InputHTMLAttributes<HTMLInputElement>) {
const [internalValue, setInternalValue] = useState(value ?? '');
useEffect(() => {
if (value !== internalValue) {
setInternalValue(value ?? '');
}
}, [value]);
return (
<div className="relative w-full">
<Input
{...props}
value={internalValue}
onChange={(e) => setInternalValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onChangeValue(internalValue);
}
}}
size="default"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<AnimatePresence>
{internalValue !== value && (
<motion.button
key="refresh"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
onClick={() => onChangeValue(internalValue)}
>
<Badge variant="secondary">
Press enter
<RefreshCcwIcon className="ml-1 h-3 w-3" />
</Badge>
</motion.button>
)}
</AnimatePresence>
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { cn } from '@/utils/cn';
import { OTPInput, OTPInputContext } from 'input-otp';
import { Dot } from 'lucide-react';
import * as React from 'react';
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
'flex items-center gap-2 has-[:disabled]:opacity-50',
containerClassName,
)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
));
InputOTP.displayName = 'InputOTP';
const InputOTPGroup = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center', className)} {...props} />
));
InputOTPGroup.displayName = 'InputOTPGroup';
const InputOTPSlot = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]!;
return (
<div
ref={ref}
className={cn(
'relative flex h-10 w-10 items-center justify-center border-y border-r border-input transition-all first:rounded-l-md first:border-l last:rounded-r-md',
isActive && 'z-10 ring-2 ring-ring ring-offset-background',
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink h-4 w-px bg-foreground duration-1000" />
</div>
)}
</div>
);
});
InputOTPSlot.displayName = 'InputOTPSlot';
const InputOTPSeparator = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
));
InputOTPSeparator.displayName = 'InputOTPSeparator';
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@@ -0,0 +1,28 @@
import type { Dispatch, SetStateAction } from 'react';
import AnimateHeight from '../animate-height';
import { Label } from './label';
import { Switch } from './switch';
type Props = {
active: boolean;
onActiveChange: (newValue: boolean) => void;
label: string;
children: React.ReactNode;
};
export function InputWithToggle({
active,
onActiveChange,
label,
children,
}: Props) {
return (
<div className="col gap-2">
<div className="flex gap-2 items-center justify-between">
<Label className="mb-0">{label}</Label>
<Switch checked={active} onCheckedChange={onActiveChange} />
</div>
<AnimateHeight open={active}>{children}</AnimateHeight>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { cn } from '@/utils/cn';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import * as React from 'react';
const inputVariant = cva(
'file: flex w-full rounded-md border border-input bg-card ring-offset-background file:border-0 file:bg-transparent file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
size: {
sm: 'h-8 px-3 py-2 ',
default: 'h-10 px-3 py-2 ',
large: 'h-12 px-4 py-3 text-lg',
},
},
defaultVariants: {
size: 'sm',
},
},
);
export type InputProps = VariantProps<typeof inputVariant> &
Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> & {
error?: string | undefined;
};
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, error, type, size, ...props }, ref) => {
return (
<input
autoComplete="off"
autoCorrect="off"
type={type}
className={cn(
inputVariant({ size, className }),
!!error && 'border-destructive',
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
export { Input };

View File

@@ -0,0 +1,221 @@
import { fancyMinutes } from '@/hooks/use-numer-formatter';
import { countries } from '@/translations/countries';
import { camelCaseToWords } from '@/utils/casing';
import { clipboard } from '@/utils/clipboard';
import { cn } from '@/utils/cn';
import { formatDateTime, formatTime } from '@/utils/date';
import type { IServiceEvent } from '@openpanel/db';
import { isToday } from 'date-fns';
import { CopyIcon } from 'lucide-react';
import { SerieIcon } from '../report-chart/common/serie-icon';
export interface KeyValueItem {
name: string;
value: any;
event?: IServiceEvent;
}
interface KeyValueGridProps {
data: KeyValueItem[];
columns?: 1 | 2 | 3 | 4;
className?: string;
rowClassName?: string;
keyClassName?: string;
valueClassName?: string;
renderKey?: (item: KeyValueItem) => React.ReactNode;
renderValue?: (item: KeyValueItem) => React.ReactNode;
onItemClick?: (item: KeyValueItem) => void;
copyable?: boolean;
}
export function KeyValueGrid({
data,
columns = 1,
className,
rowClassName,
keyClassName,
valueClassName,
renderKey,
renderValue,
onItemClick,
copyable = false,
}: KeyValueGridProps) {
const defaultRenderKey = (item: KeyValueItem) => {
const splitKey = item.name.split('.');
return (
<div className="flex items-center gap-1">
{splitKey.map((name, index) => (
<span
key={name}
className={
index === splitKey.length - 1
? 'text-foreground'
: 'text-muted-foreground'
}
>
{camelCaseToWords(name)}
{index < splitKey.length - 1 && (
<span className="text-muted-foreground">.</span>
)}
</span>
))}
</div>
);
};
const defaultRenderValue = (item: KeyValueItem) => {
return (
<FieldValue name={item.name} value={item.value} event={item.event} />
);
};
const gridCols = {
1: 'grid-cols-1',
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
};
return (
<div
className={cn('grid card overflow-hidden', gridCols[columns], className)}
>
{data.map((item, index) => (
<div
key={`${item.name}-${index}`}
className={cn(
'relative flex items-center justify-between gap-4 p-4 py-3 shadow-[0_0_0_0.5px] shadow-border group',
onItemClick && 'cursor-pointer hover:bg-muted/50',
rowClassName,
)}
onClick={() => onItemClick?.(item)}
onKeyDown={(e) => {
if (onItemClick && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onItemClick(item);
}
}}
tabIndex={onItemClick ? 0 : undefined}
role={onItemClick ? 'button' : undefined}
>
{copyable && (
<button
onClick={(e) => {
e.stopPropagation();
clipboard(item.value);
}}
type="button"
className="absolute left-2 top-1/2 -translate-y-1/2 -translate-x-full opacity-0 group-hover:translate-x-0 group-hover:opacity-100 transition-all duration-200 ease-out bg-background border border-border rounded p-1 shadow-sm z-10"
>
<CopyIcon className="size-3 shrink-0" />
</button>
)}
<div className={cn('flex-1 min-w-0 text-sm', keyClassName)}>
{renderKey ? renderKey(item) : defaultRenderKey(item)}
</div>
<div className={cn('text-right text-sm font-mono', valueClassName)}>
{renderValue ? renderValue(item) : defaultRenderValue(item)}
</div>
</div>
))}
{data.length === 0 && (
<div className="text-center text-muted-foreground py-8 col-span-full">
No data available
</div>
)}
</div>
);
}
export function FieldValue({
name,
value,
event,
}: {
name: string;
value: any;
event?: IServiceEvent;
}) {
if (!value) {
return null;
}
if (value instanceof Date) {
return isToday(value) ? formatTime(value) : formatDateTime(value);
}
if (event) {
switch (name) {
case 'osVersion':
return (
<div className="row gap-2 items-center">
<SerieIcon name={event.os} />
<span>{value}</span>
</div>
);
case 'browserVersion':
return (
<div className="row gap-2 items-center">
<SerieIcon name={event.browser} />
<span>{value}</span>
</div>
);
case 'city':
return (
<div className="row gap-2 items-center">
<SerieIcon name={event.country} />
<span>{value}</span>
</div>
);
case 'region':
return (
<div className="row gap-2 items-center">
<SerieIcon name={event.country} />
<span>{value}</span>
</div>
);
case 'properties':
return JSON.stringify(value);
case 'country':
return (
<div className="row gap-2 items-center">
<SerieIcon name={value} />
<span>{countries[value as keyof typeof countries] ?? value}</span>
</div>
);
case 'browser':
case 'os':
case 'brand':
case 'model':
case 'device':
return (
<div className="row gap-2 items-center">
<SerieIcon name={value} />
<span>{value}</span>
</div>
);
case 'duration':
return (
<div className="text-right">
<span className="text-muted-foreground">({value}ms)</span>{' '}
{fancyMinutes(value / 1000)}
</div>
);
}
}
if (value === null || value === undefined) {
return <span className="text-muted-foreground">-</span>;
}
if (typeof value === 'boolean') {
return <span>{value ? 'true' : 'false'}</span>;
}
if (typeof value === 'object') {
return <span>{JSON.stringify(value)}</span>;
}
return <span>{String(value)}</span>;
}

View File

@@ -0,0 +1,34 @@
import { cn } from '@/utils/cn';
import { Link } from '@tanstack/react-router';
interface KeyValueProps {
name: string;
value: any;
onClick?: () => void;
href?: string;
}
export function KeyValue({ href, onClick, name, value }: KeyValueProps) {
const clickable = href || onClick;
const Component = (href ? Link : onClick ? 'button' : 'div') as 'button';
return (
<Component
className={cn(
'group flex min-w-0 max-w-full divide-x self-start overflow-hidden rounded-md border border-border text-sm font-medium transition-transform',
clickable && 'hover:-translate-y-0.5',
)}
{...{ href, onClick }}
>
<div className="bg-black/5 p-1 px-2 capitalize">{name}</div>
<div
className={cn(
'font-mono overflow-hidden max-w-[300px] text-ellipsis whitespace-nowrap bg-card p-1 px-2 text-highlight',
clickable && 'group-hover:underline',
)}
>
{value}
</div>
</Component>
);
}

View File

@@ -0,0 +1,24 @@
import { cn } from '@/utils/cn';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
import * as React from 'react';
const labelVariants = cva(
'mb-3 text-sm block 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,17 @@
import { cn } from '@/utils/cn';
const padding = 'p-4 lg:p-8';
export const Padding = ({
className,
...props
}: React.HtmlHTMLAttributes<HTMLDivElement>) => {
return <div className={cn(padding, className)} {...props} />;
};
export const Spacer = ({
className,
...props
}: React.HtmlHTMLAttributes<HTMLDivElement>) => {
return <div className={cn('h-8', className)} {...props} />;
};

View File

@@ -0,0 +1,41 @@
import { cn } from '@/utils/cn';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import * as React from 'react';
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverPortal = PopoverPrimitive.Portal;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
portal?: boolean;
}
>(({ className, align = 'center', sideOffset = 4, portal, ...props }, ref) => {
const node = (
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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,
)}
style={{
boxShadow: '0 0 0 9000px rgba(0, 0, 0, 0.05)',
}}
{...props}
/>
);
if (portal) {
return <PopoverPrimitive.Portal>{node}</PopoverPrimitive.Portal>;
}
return node;
});
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverPortal };

View File

@@ -0,0 +1,41 @@
import { cn } from '@/utils/cn';
import { round } from '@/utils/math';
import * as ProgressPrimitive from '@radix-ui/react-progress';
import * as React from 'react';
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
size?: 'sm' | 'default' | 'lg';
innerClassName?: string;
}
>(({ className, innerClassName, value, size = 'default', ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
'relative h-4 w-full min-w-16 overflow-hidden rounded bg-def-200 shadow-sm',
size === 'sm' && 'h-2',
size === 'lg' && 'h-5',
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
className={cn(
'h-full w-full flex-1 rounded bg-primary transition-all',
innerClassName,
)}
style={{
transform: `translateX(-${100 - (value || 0)}%)`,
}}
/>
{value && size !== 'sm' && (
<div className="z-5 absolute bottom-0 top-0 flex items-center px-2 text-sm font-medium font-mono">
<div>{round(value, 2)}%</div>
</div>
)}
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@@ -0,0 +1,41 @@
import { cn } from '@/utils/cn';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import { Circle } from 'lucide-react';
import * as React from 'react';
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn('grid gap-2', className)}
{...props}
ref={ref}
/>
);
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
'aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View File

@@ -0,0 +1,61 @@
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import type * as React from 'react';
import { cn } from '@/lib/utils';
function ScrollArea({
className,
children,
ref,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
orientation?: 'vertical' | 'horizontal';
}) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn('relative', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
ref={ref}
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar orientation={orientation} />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };

View File

@@ -0,0 +1,185 @@
'use client';
import * as SelectPrimitive from '@radix-ui/react-select';
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
import type * as React from 'react';
import { cn } from '@/lib/utils';
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default';
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground 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 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = 'popper',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'bg-popover text-popover-foreground 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
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-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -0,0 +1,26 @@
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import type * as React from 'react';
import { cn } from '@/lib/utils';
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className,
)}
{...props}
/>
);
}
export { Separator };

View File

@@ -0,0 +1,157 @@
import { cn } from '@/utils/cn';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
import { XIcon } from 'lucide-react';
import * as React from 'react';
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/20 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 flex flex-col gap-4 overflow-y-auto rounded-lg 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 max-sm:w-[calc(100%-theme(spacing.8))]',
{
variants: {
side: {
top: 'inset-x-4 top-4 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom:
'inset-x-4 bottom-4 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'bottom-4 left-4 top-4 w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right:
'bottom-4 right-4 top-4 w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
},
},
defaultVariants: {
side: 'right',
},
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps & {
onClose?: () => void;
}
>(({ side = 'right', className, children, onClose, ...props }, ref) => (
<SheetPortal>
<SheetOverlay onClick={onClose} />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close id="close-sheet" className="hidden" />
<SheetPrimitive.Close
onClick={onClose}
className="absolute right-4 top-6 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-def-100"
>
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'relative -m-6 mb-0 flex justify-between rounded-t-lg border-b bg-def-100 p-6',
className,
)}
{...props}
>
<div className="row relative w-full items-start justify-between">
{children}
</div>
</div>
);
SheetHeader.displayName = 'SheetHeader';
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'mt-auto 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-2xl 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-muted-foreground', className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export function closeSheet() {
if (typeof document === 'undefined') return;
const element = document.querySelector('#close-sheet');
if (element instanceof HTMLElement) {
element.click();
}
}
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@@ -0,0 +1,63 @@
'use client';
import * as SliderPrimitive from '@radix-ui/react-slider';
import * as React from 'react';
import { cn } from '@/lib/utils';
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
'bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5',
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
'bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full',
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };

View File

@@ -0,0 +1,29 @@
import { useTheme } from '@/hooks/use-theme';
import { Toaster as Sonner } from 'sonner';
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-card group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton:
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton:
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...props}
/>
);
};
export { Toaster };

View File

@@ -0,0 +1,301 @@
import { cn } from '@/utils/cn';
import { type VariantProps, cva } from 'class-variance-authority';
import type * as React from 'react';
const spinnerVariants = cva('', {
variants: {
size: {
xs: 'h-3 w-3',
sm: 'h-4 w-4',
md: 'h-6 w-6',
lg: 'h-8 w-8',
xl: 'h-12 w-12',
},
variant: {
default: 'text-muted-foreground',
primary: 'text-primary',
white: 'text-white',
destructive: 'text-destructive',
},
speed: {
slow: '[animation-duration:2s]',
normal: '[animation-duration:1s]',
fast: '[animation-duration:0.5s]',
},
},
defaultVariants: {
size: 'sm',
variant: 'default',
speed: 'normal',
},
});
export interface SpinnerProps
extends Omit<React.SVGProps<SVGSVGElement>, 'size' | 'speed'>,
VariantProps<typeof spinnerVariants> {
type?: 'circle' | 'dots' | 'pulse' | 'bars' | 'ring';
className?: string;
}
const Spinner = ({
className,
size,
variant,
speed,
type = 'circle',
ref,
...props
}: SpinnerProps) => {
const baseClasses = spinnerVariants({ size, variant, speed });
const animationClass = type === 'pulse' ? 'animate-pulse' : 'animate-spin';
const spinnerClasses = cn(baseClasses, animationClass, className);
switch (type) {
case 'circle':
return (
<svg
ref={ref}
className={spinnerClasses}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
case 'dots':
return (
<svg
ref={ref}
className={baseClasses}
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 24 24"
{...props}
>
<circle cx="4" cy="12" r="3">
<animate
attributeName="opacity"
dur="1s"
repeatCount="indefinite"
values="0;1;0"
begin="0s"
/>
</circle>
<circle cx="12" cy="12" r="3">
<animate
attributeName="opacity"
dur="1s"
repeatCount="indefinite"
values="0;1;0"
begin="0.2s"
/>
</circle>
<circle cx="20" cy="12" r="3">
<animate
attributeName="opacity"
dur="1s"
repeatCount="indefinite"
values="0;1;0"
begin="0.4s"
/>
</circle>
</svg>
);
case 'pulse':
return (
<div
ref={ref as unknown as React.RefObject<HTMLDivElement>}
className={cn(
'rounded-full bg-current animate-pulse',
spinnerClasses,
)}
style={{
animationDuration:
speed === 'slow' ? '2s' : speed === 'fast' ? '0.5s' : '1s',
}}
{...(props as React.HTMLAttributes<HTMLDivElement>)}
/>
);
case 'bars':
return (
<svg
ref={ref}
className={spinnerClasses}
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 24 24"
{...props}
>
<rect x="1" y="6" width="2.8" height="12">
<animate
attributeName="height"
dur="1s"
repeatCount="indefinite"
values="12;4;12"
begin="0s"
/>
<animate
attributeName="y"
dur="1s"
repeatCount="indefinite"
values="6;10;6"
begin="0s"
/>
</rect>
<rect x="5.8" y="6" width="2.8" height="12">
<animate
attributeName="height"
dur="1s"
repeatCount="indefinite"
values="12;4;12"
begin="0.2s"
/>
<animate
attributeName="y"
dur="1s"
repeatCount="indefinite"
values="6;10;6"
begin="0.2s"
/>
</rect>
<rect x="10.6" y="6" width="2.8" height="12">
<animate
attributeName="height"
dur="1s"
repeatCount="indefinite"
values="12;4;12"
begin="0.4s"
/>
<animate
attributeName="y"
dur="1s"
repeatCount="indefinite"
values="6;10;6"
begin="0.4s"
/>
</rect>
<rect x="15.4" y="6" width="2.8" height="12">
<animate
attributeName="height"
dur="1s"
repeatCount="indefinite"
values="12;4;12"
begin="0.6s"
/>
<animate
attributeName="y"
dur="1s"
repeatCount="indefinite"
values="6;10;6"
begin="0.6s"
/>
</rect>
<rect x="20.2" y="6" width="2.8" height="12">
<animate
attributeName="height"
dur="1s"
repeatCount="indefinite"
values="12;4;12"
begin="0.8s"
/>
<animate
attributeName="y"
dur="1s"
repeatCount="indefinite"
values="6;10;6"
begin="0.8s"
/>
</rect>
</svg>
);
case 'ring':
return (
<svg
ref={ref}
className={spinnerClasses}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<circle
className="opacity-75"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
strokeDasharray="32"
strokeDashoffset="32"
>
<animate
attributeName="stroke-dasharray"
dur="2s"
repeatCount="indefinite"
values="0 32;16 16;0 32;0 32"
/>
<animate
attributeName="stroke-dashoffset"
dur="2s"
repeatCount="indefinite"
values="0;-16;-32;-32"
/>
</circle>
</svg>
);
default:
return (
<svg
ref={ref}
className={spinnerClasses}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
}
};
export { Spinner, spinnerVariants };

View File

@@ -0,0 +1,26 @@
import { cn } from '@/utils/cn';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import * as React from 'react';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'data-[state=checked]:bg-highlight peer inline-flex h-4 w-7 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=unchecked]:bg-input',
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-3 w-3 rounded-full bg-card shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-3 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@@ -0,0 +1,128 @@
import type * as React from 'react';
import { cn } from '@/lib/utils';
export function TableButtons({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn('mb-2 row flex-wrap items-center gap-2', className)}>
{children}
</div>
);
}
function Table({ className, ...props }: React.ComponentProps<'table'>) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn('w-full caption-bottom bg-card', className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return (
<thead
data-slot="table-header"
className={cn('[&_tr]:border-b', className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return (
<tbody
data-slot="table-body"
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return (
<tfoot
data-slot="table-footer"
className={cn(
'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0',
className,
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return (
<tr
data-slot="table-row"
className={cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return (
<th
data-slot="table-head"
className={cn(
'text-[10px] uppercase text-foreground h-10 px-4 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return (
<td
data-slot="table-cell"
className={cn(
'p-2 px-4 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] max-w-94',
className,
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<'caption'>) {
return (
<caption
data-slot="table-caption"
className={cn('text-muted-foreground mt-4 text-sm', className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@@ -0,0 +1,56 @@
import { cn } from '@/utils/cn';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import * as React from 'react';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex w-full h-auto items-center justify-start border-b border-border text-muted-foreground',
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'group inline-flex items-center justify-center whitespace-nowrap px-3 py-1 font-medium text-muted-foreground transition-all border-b-2 border-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-foreground data-[state=active]:border-foreground',
className,
)}
{...props}
>
<span className="group-hover:bg-def-300 group-hover:text-foreground p-0.5 px-1.5 rounded-sm">
{props.children}
</span>
</TabsPrimitive.Trigger>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,22 @@
import { cn } from '@/utils/cn';
import * as React from 'react';
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-card px-3 py-2 ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = 'Textarea';
export { Textarea };

View File

@@ -0,0 +1,127 @@
import { cn } from '@/utils/cn';
import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import * as React from 'react';
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className,
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-card text-foreground',
destructive:
'destructive group border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 font-medium ring-offset-background transition-colors hover:bg-def-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
className,
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn(' font-semibold', className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn(' opacity-90', className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@@ -0,0 +1,29 @@
import { useTheme } from '@/hooks/use-theme';
import { Toaster as Sonner } from 'sonner';
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-card group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton:
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton:
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...props}
/>
);
};
export { Toaster };

View File

@@ -0,0 +1,58 @@
import { toggleVariants } from '@/components/ui/toggle';
import { cn } from '@/utils/cn';
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
import type { VariantProps } from 'class-variance-authority';
import * as React from 'react';
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: 'default',
variant: 'default',
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn('flex items-center justify-center gap-1', className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };

View File

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

View File

@@ -0,0 +1,83 @@
import { cn } from '@/utils/cn';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import * as React from 'react';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipPortal = TooltipPrimitive.Portal;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
disabled?: boolean;
}
>(({ className, sideOffset = 4, disabled, ...props }, ref) =>
disabled ? null : (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 rounded-md border bg-background p-4 py-2.5 animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
),
);
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
interface TooltiperProps {
asChild?: boolean;
content: React.ReactNode;
children: React.ReactNode;
className?: string;
tooltipClassName?: string;
onClick?: () => void;
side?: 'top' | 'right' | 'bottom' | 'left';
align?: 'start' | 'center' | 'end';
delayDuration?: number;
sideOffset?: number;
disabled?: boolean;
}
export function Tooltiper({
asChild,
content,
children,
className,
tooltipClassName,
onClick,
side,
delayDuration = 0,
sideOffset = 10,
disabled = false,
align,
}: TooltiperProps) {
if (disabled) return children;
return (
<Tooltip delayDuration={delayDuration}>
<TooltipTrigger
asChild={asChild}
className={className}
onClick={onClick}
type="button"
>
{children}
</TooltipTrigger>
<TooltipPortal>
<TooltipContent
sideOffset={sideOffset}
side={side}
className={tooltipClassName}
align={align}
>
{content}
</TooltipContent>
</TooltipPortal>
</Tooltip>
);
}