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:
committed by
GitHub
parent
436e81ecc9
commit
81a7e5d62e
55
apps/start/src/components/ui/RenderDots.tsx
Normal file
55
apps/start/src/components/ui/RenderDots.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
apps/start/src/components/ui/accordion.tsx
Normal file
55
apps/start/src/components/ui/accordion.tsx
Normal 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 };
|
||||
138
apps/start/src/components/ui/alert-dialog.tsx
Normal file
138
apps/start/src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
};
|
||||
63
apps/start/src/components/ui/alert.tsx
Normal file
63
apps/start/src/components/ui/alert.tsx
Normal 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 };
|
||||
5
apps/start/src/components/ui/aspect-ratio.tsx
Normal file
5
apps/start/src/components/ui/aspect-ratio.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio';
|
||||
|
||||
const AspectRatio = AspectRatioPrimitive.Root;
|
||||
|
||||
export { AspectRatio };
|
||||
47
apps/start/src/components/ui/avatar.tsx
Normal file
47
apps/start/src/components/ui/avatar.tsx
Normal 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 };
|
||||
46
apps/start/src/components/ui/badge.tsx
Normal file
46
apps/start/src/components/ui/badge.tsx
Normal 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 };
|
||||
219
apps/start/src/components/ui/button.tsx
Normal file
219
apps/start/src/components/ui/button.tsx
Normal 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 };
|
||||
215
apps/start/src/components/ui/calendar.tsx
Normal file
215
apps/start/src/components/ui/calendar.tsx
Normal 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 };
|
||||
264
apps/start/src/components/ui/carousel.tsx
Normal file
264
apps/start/src/components/ui/carousel.tsx
Normal 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,
|
||||
};
|
||||
58
apps/start/src/components/ui/checkbox.tsx
Normal file
58
apps/start/src/components/ui/checkbox.tsx
Normal 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 };
|
||||
147
apps/start/src/components/ui/combobox-advanced.tsx
Normal file
147
apps/start/src/components/ui/combobox-advanced.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
223
apps/start/src/components/ui/combobox-events.tsx
Normal file
223
apps/start/src/components/ui/combobox-events.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
165
apps/start/src/components/ui/combobox.tsx
Normal file
165
apps/start/src/components/ui/combobox.tsx
Normal 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 "{search}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
184
apps/start/src/components/ui/command.tsx
Normal file
184
apps/start/src/components/ui/command.tsx
Normal 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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
82
apps/start/src/components/ui/data-table/data-table-config.ts
Normal file
82
apps/start/src/components/ui/data-table/data-table-config.ts
Normal 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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
34
apps/start/src/components/ui/data-table/data-table-hooks.tsx
Normal file
34
apps/start/src/components/ui/data-table/data-table-hooks.tsx
Normal 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 };
|
||||
};
|
||||
@@ -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,
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
237
apps/start/src/components/ui/data-table/data-table-toolbar.tsx
Normal file
237
apps/start/src/components/ui/data-table/data-table-toolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
138
apps/start/src/components/ui/data-table/data-table.tsx
Normal file
138
apps/start/src/components/ui/data-table/data-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
227
apps/start/src/components/ui/data-table/use-table.tsx
Normal file
227
apps/start/src/components/ui/data-table/use-table.tsx
Normal 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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
121
apps/start/src/components/ui/date-time.tsx
Normal file
121
apps/start/src/components/ui/date-time.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
apps/start/src/components/ui/dialog.tsx
Normal file
146
apps/start/src/components/ui/dialog.tsx
Normal 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,
|
||||
};
|
||||
244
apps/start/src/components/ui/dropdown-menu.tsx
Normal file
244
apps/start/src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
};
|
||||
24
apps/start/src/components/ui/gradient-background.tsx
Normal file
24
apps/start/src/components/ui/gradient-background.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
apps/start/src/components/ui/input-date-time.tsx
Normal file
88
apps/start/src/components/ui/input-date-time.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
apps/start/src/components/ui/input-enter.tsx
Normal file
58
apps/start/src/components/ui/input-enter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
apps/start/src/components/ui/input-otp.tsx
Normal file
68
apps/start/src/components/ui/input-otp.tsx
Normal 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 };
|
||||
28
apps/start/src/components/ui/input-with-toggle.tsx
Normal file
28
apps/start/src/components/ui/input-with-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
apps/start/src/components/ui/input.tsx
Normal file
46
apps/start/src/components/ui/input.tsx
Normal 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 };
|
||||
221
apps/start/src/components/ui/key-value-grid.tsx
Normal file
221
apps/start/src/components/ui/key-value-grid.tsx
Normal 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>;
|
||||
}
|
||||
34
apps/start/src/components/ui/key-value.tsx
Normal file
34
apps/start/src/components/ui/key-value.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
apps/start/src/components/ui/label.tsx
Normal file
24
apps/start/src/components/ui/label.tsx
Normal 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 };
|
||||
17
apps/start/src/components/ui/padding.tsx
Normal file
17
apps/start/src/components/ui/padding.tsx
Normal 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} />;
|
||||
};
|
||||
41
apps/start/src/components/ui/popover.tsx
Normal file
41
apps/start/src/components/ui/popover.tsx
Normal 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 };
|
||||
41
apps/start/src/components/ui/progress.tsx
Normal file
41
apps/start/src/components/ui/progress.tsx
Normal 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 };
|
||||
41
apps/start/src/components/ui/radio-group.tsx
Normal file
41
apps/start/src/components/ui/radio-group.tsx
Normal 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 };
|
||||
61
apps/start/src/components/ui/scroll-area.tsx
Normal file
61
apps/start/src/components/ui/scroll-area.tsx
Normal 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 };
|
||||
185
apps/start/src/components/ui/select.tsx
Normal file
185
apps/start/src/components/ui/select.tsx
Normal 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,
|
||||
};
|
||||
26
apps/start/src/components/ui/separator.tsx
Normal file
26
apps/start/src/components/ui/separator.tsx
Normal 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 };
|
||||
157
apps/start/src/components/ui/sheet.tsx
Normal file
157
apps/start/src/components/ui/sheet.tsx
Normal 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,
|
||||
};
|
||||
63
apps/start/src/components/ui/slider.tsx
Normal file
63
apps/start/src/components/ui/slider.tsx
Normal 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 };
|
||||
29
apps/start/src/components/ui/sonner.tsx
Normal file
29
apps/start/src/components/ui/sonner.tsx
Normal 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 };
|
||||
301
apps/start/src/components/ui/spinner.tsx
Normal file
301
apps/start/src/components/ui/spinner.tsx
Normal 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 };
|
||||
26
apps/start/src/components/ui/switch.tsx
Normal file
26
apps/start/src/components/ui/switch.tsx
Normal 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 };
|
||||
128
apps/start/src/components/ui/table.tsx
Normal file
128
apps/start/src/components/ui/table.tsx
Normal 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,
|
||||
};
|
||||
56
apps/start/src/components/ui/tabs.tsx
Normal file
56
apps/start/src/components/ui/tabs.tsx
Normal 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 };
|
||||
22
apps/start/src/components/ui/textarea.tsx
Normal file
22
apps/start/src/components/ui/textarea.tsx
Normal 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 };
|
||||
127
apps/start/src/components/ui/toast.tsx
Normal file
127
apps/start/src/components/ui/toast.tsx
Normal 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,
|
||||
};
|
||||
29
apps/start/src/components/ui/toaster.tsx
Normal file
29
apps/start/src/components/ui/toaster.tsx
Normal 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 };
|
||||
58
apps/start/src/components/ui/toggle-group.tsx
Normal file
58
apps/start/src/components/ui/toggle-group.tsx
Normal 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 };
|
||||
43
apps/start/src/components/ui/toggle.tsx
Normal file
43
apps/start/src/components/ui/toggle.tsx
Normal 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 };
|
||||
83
apps/start/src/components/ui/tooltip.tsx
Normal file
83
apps/start/src/components/ui/tooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user