Replace dashboard BarChart with 21st.dev LineChart component

Swap the main site dashboard chart from a bar chart to a line chart
using 21st.dev's line-charts-6 component with dot grid background,
glow shadow, and animated active dots. Add Badge trend indicators
on metric cards using Phosphor icons. All existing features preserved
(annotations, comparison, export, live indicator, interval controls).

New UI primitives: line-charts-6, badge-2, card, button-1, avatar.
Added shadcn-compatible CSS variables and Tailwind color mappings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Usman Baig
2026-03-09 22:53:35 +01:00
parent 5721d25291
commit 033d735c3a
11 changed files with 3213 additions and 337 deletions

67
components/ui/avatar.tsx Normal file
View File

@@ -0,0 +1,67 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { cva, VariantProps } from 'class-variance-authority';
import { Avatar as AvatarPrimitive } from 'radix-ui';
const avatarStatusVariants = cva('flex items-center rounded-full size-2 border-2 border-background', {
variants: {
variant: {
online: 'bg-green-600',
offline: 'bg-zinc-600 dark:bg-zinc-300',
busy: 'bg-yellow-600',
away: 'bg-blue-600',
},
},
defaultVariants: {
variant: 'online',
},
});
function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root data-slot="avatar" className={cn('relative flex shrink-0 size-10', className)} {...props} />
);
}
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<div className={cn('relative overflow-hidden rounded-full', className)}>
<AvatarPrimitive.Image data-slot="avatar-image" className={cn('aspect-square h-full w-full')} {...props} />
</div>
);
}
function AvatarFallback({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
'flex h-full w-full items-center justify-center rounded-full border border-border bg-accent text-accent-foreground text-xs',
className,
)}
{...props}
/>
);
}
function AvatarIndicator({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
data-slot="avatar-indicator"
className={cn('absolute flex size-6 items-center justify-center', className)}
{...props}
/>
);
}
function AvatarStatus({
className,
variant,
...props
}: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof avatarStatusVariants>) {
return <div data-slot="avatar-status" className={cn(avatarStatusVariants({ variant }), className)} {...props} />;
}
export { Avatar, AvatarFallback, AvatarImage, AvatarIndicator, AvatarStatus, avatarStatusVariants };

230
components/ui/badge-2.tsx Normal file
View File

@@ -0,0 +1,230 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot as SlotPrimitive } from 'radix-ui';
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {
asChild?: boolean;
dotClassName?: string;
disabled?: boolean;
}
export interface BadgeButtonProps
extends React.ButtonHTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeButtonVariants> {
asChild?: boolean;
}
export type BadgeDotProps = React.HTMLAttributes<HTMLSpanElement>;
const badgeVariants = cva(
'inline-flex items-center justify-center border border-transparent font-medium focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 [&_svg]:-ms-px [&_svg]:shrink-0',
{
variants: {
variant: {
primary: 'bg-primary text-primary-foreground',
secondary: 'bg-secondary text-secondary-foreground',
success:
'bg-[var(--color-success-accent,#22c55e)] text-[var(--color-success-foreground,#ffffff)]',
warning:
'bg-[var(--color-warning-accent,#eab308)] text-[var(--color-warning-foreground,#ffffff)]',
info: 'bg-[var(--color-info-accent,#8b5cf6)] text-[var(--color-info-foreground,#ffffff)]',
outline: 'bg-transparent border border-border text-secondary-foreground',
destructive: 'bg-destructive text-destructive-foreground',
},
appearance: {
default: '',
light: '',
outline: '',
ghost: 'border-transparent bg-transparent',
},
disabled: {
true: 'opacity-50 pointer-events-none',
},
size: {
lg: 'rounded-md px-[0.5rem] h-7 min-w-7 gap-1.5 text-xs [&_svg]:size-3.5',
md: 'rounded-md px-[0.45rem] h-6 min-w-6 gap-1.5 text-xs [&_svg]:size-3.5 ',
sm: 'rounded-sm px-[0.325rem] h-5 min-w-5 gap-1 text-[0.6875rem] leading-[0.75rem] [&_svg]:size-3',
xs: 'rounded-sm px-[0.25rem] h-4 min-w-4 gap-1 text-[0.625rem] leading-[0.5rem] [&_svg]:size-3',
},
shape: {
default: '',
circle: 'rounded-full',
},
},
compoundVariants: [
/* Light */
{
variant: 'primary',
appearance: 'light',
className:
'text-blue-700 bg-blue-50 dark:bg-blue-950 dark:text-blue-600',
},
{
variant: 'secondary',
appearance: 'light',
className: 'bg-secondary dark:bg-secondary/50 text-secondary-foreground',
},
{
variant: 'success',
appearance: 'light',
className:
'text-green-800 bg-green-100 dark:bg-green-950 dark:text-green-600',
},
{
variant: 'warning',
appearance: 'light',
className:
'text-yellow-700 bg-yellow-100 dark:bg-yellow-950 dark:text-yellow-600',
},
{
variant: 'info',
appearance: 'light',
className:
'text-violet-700 bg-violet-100 dark:bg-violet-950 dark:text-violet-400',
},
{
variant: 'destructive',
appearance: 'light',
className:
'text-red-700 bg-red-50 dark:bg-red-950 dark:text-red-600',
},
/* Outline */
{
variant: 'primary',
appearance: 'outline',
className:
'text-blue-700 border-blue-100 bg-blue-50 dark:bg-blue-950 dark:border-blue-900 dark:text-blue-600',
},
{
variant: 'success',
appearance: 'outline',
className:
'text-green-700 border-green-200 bg-green-50 dark:bg-green-950 dark:border-green-900 dark:text-green-600',
},
{
variant: 'warning',
appearance: 'outline',
className:
'text-yellow-700 border-yellow-200 bg-yellow-50 dark:bg-yellow-950 dark:border-yellow-900 dark:text-yellow-600',
},
{
variant: 'info',
appearance: 'outline',
className:
'text-violet-700 border-violet-100 bg-violet-50 dark:bg-violet-950 dark:border-violet-900 dark:text-violet-400',
},
{
variant: 'destructive',
appearance: 'outline',
className:
'text-red-700 border-red-100 bg-red-50 dark:bg-red-950 dark:border-red-900 dark:text-red-600',
},
/* Ghost */
{
variant: 'primary',
appearance: 'ghost',
className: 'text-primary',
},
{
variant: 'secondary',
appearance: 'ghost',
className: 'text-secondary-foreground',
},
{
variant: 'success',
appearance: 'ghost',
className: 'text-green-500',
},
{
variant: 'warning',
appearance: 'ghost',
className: 'text-yellow-500',
},
{
variant: 'info',
appearance: 'ghost',
className: 'text-violet-500',
},
{
variant: 'destructive',
appearance: 'ghost',
className: 'text-destructive',
},
{ size: 'lg', appearance: 'ghost', className: 'px-0' },
{ size: 'md', appearance: 'ghost', className: 'px-0' },
{ size: 'sm', appearance: 'ghost', className: 'px-0' },
{ size: 'xs', appearance: 'ghost', className: 'px-0' },
],
defaultVariants: {
variant: 'primary',
appearance: 'default',
size: 'md',
},
},
);
const badgeButtonVariants = cva(
'cursor-pointer transition-all inline-flex items-center justify-center leading-none size-3.5 [&>svg]:opacity-100! [&>svg]:size-3.5 p-0 rounded-md -me-0.5 opacity-60 hover:opacity-100',
{
variants: {
variant: {
default: '',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function Badge({
className,
variant,
size,
appearance,
shape,
asChild = false,
disabled,
...props
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? SlotPrimitive.Slot : 'span';
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant, size, appearance, shape, disabled }), className)}
{...props}
/>
);
}
function BadgeButton({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'button'> & VariantProps<typeof badgeButtonVariants> & { asChild?: boolean }) {
const Comp = asChild ? SlotPrimitive.Slot : 'span';
return (
<Comp
data-slot="badge-button"
className={cn(badgeButtonVariants({ variant, className }))}
role="button"
{...props}
/>
);
}
function BadgeDot({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="badge-dot"
className={cn('size-1.5 rounded-full bg-[currentColor] opacity-75', className)}
{...props}
/>
);
}
export { Badge, BadgeButton, BadgeDot, badgeVariants };

412
components/ui/button-1.tsx Normal file
View File

@@ -0,0 +1,412 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { CaretDown } from '@phosphor-icons/react';
import { Slot as SlotPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'cursor-pointer group whitespace-nowrap focus-visible:outline-hidden inline-flex items-center justify-center has-data-[arrow=true]:justify-between whitespace-nowrap text-sm font-medium ring-offset-background transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0',
{
variants: {
variant: {
primary: 'bg-primary text-primary-foreground hover:bg-primary/90 data-[state=open]:bg-primary/90',
mono: 'bg-zinc-950 text-white dark:bg-zinc-300 dark:text-black hover:bg-zinc-950/90 dark:hover:bg-zinc-300/90 data-[state=open]:bg-zinc-950/90 dark:data-[state=open]:bg-zinc-300/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90 data-[state=open]:bg-destructive/90',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/90 data-[state=open]:bg-secondary/90',
outline: 'bg-background text-accent-foreground border border-input hover:bg-accent data-[state=open]:bg-accent',
dashed:
'text-accent-foreground border border-input border-dashed bg-background hover:bg-accent hover:text-accent-foreground data-[state=open]:text-accent-foreground',
ghost:
'text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
dim: 'text-muted-foreground hover:text-foreground data-[state=open]:text-foreground',
foreground: '',
inverse: '',
},
appearance: {
default: '',
ghost: '',
},
underline: {
solid: '',
dashed: '',
},
underlined: {
solid: '',
dashed: '',
},
size: {
lg: 'h-10 rounded-md px-4 text-sm gap-1.5 [&_svg:not([class*=size-])]:size-4',
md: 'h-8.5 rounded-md px-3 gap-1.5 text-[0.8125rem] leading-[--text-sm--line-height] [&_svg:not([class*=size-])]:size-4',
sm: 'h-7 rounded-md px-2.5 gap-1.25 text-xs [&_svg:not([class*=size-])]:size-3.5',
icon: 'size-8.5 rounded-md [&_svg:not([class*=size-])]:size-4 shrink-0',
},
autoHeight: {
true: '',
false: '',
},
shape: {
default: '',
circle: 'rounded-full',
},
mode: {
default: 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
icon: 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
link: 'text-primary h-auto p-0 bg-transparent rounded-none hover:bg-transparent data-[state=open]:bg-transparent',
input: `
justify-start font-normal hover:bg-background [&_svg]:transition-colors [&_svg]:hover:text-foreground data-[state=open]:bg-background
focus-visible:border-ring focus-visible:outline-hidden focus-visible:ring-[3px] focus-visible:ring-ring/30
[[data-state=open]>&]:border-ring [[data-state=open]>&]:outline-hidden [[data-state=open]>&]:ring-[3px]
[[data-state=open]>&]:ring-ring/30
aria-invalid:border-destructive/60 aria-invalid:ring-destructive/10 dark:aria-invalid:border-destructive dark:aria-invalid:ring-destructive/20
in-data-[invalid=true]:border-destructive/60 in-data-[invalid=true]:ring-destructive/10 dark:in-data-[invalid=true]:border-destructive dark:in-data-[invalid=true]:ring-destructive/20
`,
},
placeholder: {
true: 'text-muted-foreground',
false: '',
},
},
compoundVariants: [
// Icons opacity for default mode
{
variant: 'ghost',
mode: 'default',
className: '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
},
{
variant: 'outline',
mode: 'default',
className: '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
},
{
variant: 'dashed',
mode: 'default',
className: '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
},
{
variant: 'secondary',
mode: 'default',
className: '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
},
// Icons opacity for default mode
{
variant: 'outline',
mode: 'input',
className: '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
},
{
variant: 'outline',
mode: 'icon',
className: '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
},
// Auto height
{
size: 'md',
autoHeight: true,
className: 'h-auto min-h-8.5',
},
{
size: 'sm',
autoHeight: true,
className: 'h-auto min-h-7',
},
{
size: 'lg',
autoHeight: true,
className: 'h-auto min-h-10',
},
// Shadow support
{
variant: 'primary',
mode: 'default',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'mono',
mode: 'default',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'secondary',
mode: 'default',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'outline',
mode: 'default',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'dashed',
mode: 'default',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'destructive',
mode: 'default',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
// Shadow support
{
variant: 'primary',
mode: 'icon',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'mono',
mode: 'icon',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'secondary',
mode: 'icon',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'outline',
mode: 'icon',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'dashed',
mode: 'icon',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
{
variant: 'destructive',
mode: 'icon',
appearance: 'default',
className: 'shadow-xs shadow-black/5',
},
// Link
{
variant: 'primary',
mode: 'link',
underline: 'solid',
className:
'font-medium text-primary hover:text-primary/90 [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-solid',
},
{
variant: 'primary',
mode: 'link',
underline: 'dashed',
className:
'font-medium text-primary hover:text-primary/90 [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-dashed decoration-1',
},
{
variant: 'primary',
mode: 'link',
underlined: 'solid',
className:
'font-medium text-primary hover:text-primary/90 [&_svg:not([role=img]):not([class*=text-])]:opacity-60 underline underline-offset-4 decoration-solid',
},
{
variant: 'primary',
mode: 'link',
underlined: 'dashed',
className:
'font-medium text-primary hover:text-primary/90 [&_svg]:opacity-60 underline underline-offset-4 decoration-dashed decoration-1',
},
{
variant: 'inverse',
mode: 'link',
underline: 'solid',
className:
'font-medium text-inherit [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-solid',
},
{
variant: 'inverse',
mode: 'link',
underline: 'dashed',
className:
'font-medium text-inherit [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-dashed decoration-1',
},
{
variant: 'inverse',
mode: 'link',
underlined: 'solid',
className:
'font-medium text-inherit [&_svg:not([role=img]):not([class*=text-])]:opacity-60 underline underline-offset-4 decoration-solid',
},
{
variant: 'inverse',
mode: 'link',
underlined: 'dashed',
className:
'font-medium text-inherit [&_svg:not([role=img]):not([class*=text-])]:opacity-60 underline underline-offset-4 decoration-dashed decoration-1',
},
{
variant: 'foreground',
mode: 'link',
underline: 'solid',
className:
'font-medium text-foreground [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-solid',
},
{
variant: 'foreground',
mode: 'link',
underline: 'dashed',
className:
'font-medium text-foreground [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-dashed decoration-1',
},
{
variant: 'foreground',
mode: 'link',
underlined: 'solid',
className:
'font-medium text-foreground [&_svg:not([role=img]):not([class*=text-])]:opacity-60 underline underline-offset-4 decoration-solid',
},
{
variant: 'foreground',
mode: 'link',
underlined: 'dashed',
className:
'font-medium text-foreground [&_svg:not([role=img]):not([class*=text-])]:opacity-60 underline underline-offset-4 decoration-dashed decoration-1',
},
// Ghost
{
variant: 'primary',
appearance: 'ghost',
className: 'bg-transparent text-primary/90 hover:bg-primary/5 data-[state=open]:bg-primary/5',
},
{
variant: 'destructive',
appearance: 'ghost',
className: 'bg-transparent text-destructive/90 hover:bg-destructive/5 data-[state=open]:bg-destructive/5',
},
{
variant: 'ghost',
mode: 'icon',
className: 'text-muted-foreground',
},
// Size
{
size: 'sm',
mode: 'icon',
className: 'w-7 h-7 p-0 [&_svg:not([class*=size-])]:size-3.5',
},
{
size: 'md',
mode: 'icon',
className: 'w-8.5 h-8.5 p-0 [&_svg:not([class*=size-])]:size-4',
},
{
size: 'icon',
className: 'w-8.5 h-8.5 p-0 [&_svg:not([class*=size-])]:size-4',
},
{
size: 'lg',
mode: 'icon',
className: 'w-10 h-10 p-0 [&_svg:not([class*=size-])]:size-4',
},
// Input mode
{
mode: 'input',
placeholder: true,
variant: 'outline',
className: 'font-normal text-muted-foreground',
},
{
mode: 'input',
variant: 'outline',
size: 'sm',
className: 'gap-1.25',
},
{
mode: 'input',
variant: 'outline',
size: 'md',
className: 'gap-1.5',
},
{
mode: 'input',
variant: 'outline',
size: 'lg',
className: 'gap-1.5',
},
],
defaultVariants: {
variant: 'primary',
mode: 'default',
size: 'md',
shape: 'default',
appearance: 'default',
},
},
);
function Button({
className,
selected,
variant,
shape,
appearance,
mode,
size,
autoHeight,
underlined,
underline,
asChild = false,
placeholder = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
selected?: boolean;
asChild?: boolean;
}) {
const Comp = asChild ? SlotPrimitive.Slot : 'button';
return (
<Comp
data-slot="button"
className={cn(
buttonVariants({
variant,
size,
shape,
appearance,
mode,
autoHeight,
placeholder,
underlined,
underline,
className,
}),
asChild && props.disabled && 'pointer-events-none opacity-50',
)}
{...(selected && { 'data-state': 'open' })}
{...props}
/>
);
}
interface ButtonArrowProps extends React.SVGProps<SVGSVGElement> {
icon?: React.ComponentType<{ className?: string }>;
}
function ButtonArrow({ icon: Icon = CaretDown, className, ...props }: ButtonArrowProps) {
return <Icon data-slot="button-arrow" className={cn('ms-auto -me-1', className)} {...(props as Record<string, unknown>)} />;
}
export { Button, ButtonArrow, buttonVariants };

147
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,147 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
// Define CardContext
type CardContextType = {
variant: 'default' | 'accent';
};
const CardContext = React.createContext<CardContextType>({
variant: 'default', // Default value
});
// Hook to use CardContext
const useCardContext = () => {
const context = React.useContext(CardContext);
if (!context) {
throw new Error('useCardContext must be used within a Card component');
}
return context;
};
// Variants
const cardVariants = cva('flex flex-col items-stretch text-card-foreground rounded-xl', {
variants: {
variant: {
default: 'bg-card border border-border shadow-xs black/5',
accent: 'bg-muted shadow-xs p-1',
},
},
defaultVariants: {
variant: 'default',
},
});
const cardHeaderVariants = cva('flex items-center justify-between flex-wrap px-5 min-h-14 gap-2.5', {
variants: {
variant: {
default: 'border-b border-border',
accent: '',
},
},
defaultVariants: {
variant: 'default',
},
});
const cardContentVariants = cva('grow p-5', {
variants: {
variant: {
default: '',
accent: 'bg-card rounded-t-xl [&:last-child]:rounded-b-xl',
},
},
defaultVariants: {
variant: 'default',
},
});
const cardTableVariants = cva('grid grow', {
variants: {
variant: {
default: '',
accent: 'bg-card rounded-xl',
},
},
defaultVariants: {
variant: 'default',
},
});
const cardFooterVariants = cva('flex items-center px-5 min-h-14', {
variants: {
variant: {
default: 'border-t border-border',
accent: 'bg-card rounded-b-xl mt-[2px]',
},
},
defaultVariants: {
variant: 'default',
},
});
// Card Component
function Card({
className,
variant = 'default',
...props
}: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof cardVariants>) {
return (
<CardContext.Provider value={{ variant: variant || 'default' }}>
<div data-slot="card" className={cn(cardVariants({ variant }), className)} {...props} />
</CardContext.Provider>
);
}
// CardHeader Component
function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
const { variant } = useCardContext();
return <div data-slot="card-header" className={cn(cardHeaderVariants({ variant }), className)} {...props} />;
}
// CardContent Component
function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
const { variant } = useCardContext();
return <div data-slot="card-content" className={cn(cardContentVariants({ variant }), className)} {...props} />;
}
// CardTable Component
function CardTable({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
const { variant } = useCardContext();
return <div data-slot="card-table" className={cn(cardTableVariants({ variant }), className)} {...props} />;
}
// CardFooter Component
function CardFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
const { variant } = useCardContext();
return <div data-slot="card-footer" className={cn(cardFooterVariants({ variant }), className)} {...props} />;
}
// Other Components
function CardHeading({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div data-slot="card-heading" className={cn('space-y-1', className)} {...props} />;
}
function CardToolbar({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div data-slot="card-toolbar" className={cn('flex items-center gap-2.5', className)} {...props} />;
}
function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h3
data-slot="card-title"
className={cn('text-base font-semibold leading-none tracking-tight', className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div data-slot="card-description" className={cn('text-sm text-muted-foreground', className)} {...props} />;
}
// Exports
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardHeading, CardTable, CardTitle, CardToolbar };

View File

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