mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 21:11:53 +00:00
feat(ui): new card components and derivates for overview (#8921)
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/shadcn",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# shadcn Components
|
||||
|
||||
This directory contains all shadcn/ui based components for the Prowler application.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
shadcn/
|
||||
├── card.tsx # shadcn Card component
|
||||
├── resource-stats-card/ # Custom ResourceStatsCard built on shadcn
|
||||
│ ├── resource-stats-card.tsx
|
||||
│ ├── resource-stats-card.example.tsx
|
||||
│ └── index.ts
|
||||
├── index.ts # Barrel exports
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
All shadcn components can be imported from `@/components/shadcn`:
|
||||
|
||||
```tsx
|
||||
import { Card, CardHeader, CardContent } from "@/components/shadcn";
|
||||
import { ResourceStatsCard } from "@/components/shadcn";
|
||||
```
|
||||
|
||||
## Adding New shadcn Components
|
||||
|
||||
When adding new shadcn components using the CLI:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add [component-name]
|
||||
```
|
||||
|
||||
The component will be automatically added to this directory due to the configuration in `components.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"aliases": {
|
||||
"ui": "@/components/shadcn"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Component Guidelines
|
||||
|
||||
1. **shadcn base components** - Use as-is from shadcn/ui (e.g., `card.tsx`)
|
||||
2. **Custom components built on shadcn** - Create in subdirectories (e.g., `resource-stats-card/`)
|
||||
3. **CVA variants** - Use Class Variance Authority for type-safe variants
|
||||
4. **Theme support** - Include `dark:` classes for dark/light theme compatibility
|
||||
5. **TypeScript** - Always export types and use proper typing
|
||||
|
||||
## Resources
|
||||
|
||||
- [shadcn/ui Documentation](https://ui.shadcn.com)
|
||||
- [CVA Documentation](https://cva.style/docs)
|
||||
- [Tailwind CSS Documentation](https://tailwindcss.com/docs)
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
export {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "./card";
|
||||
export {
|
||||
ResourceStatsCard,
|
||||
ResourceStatsCardContainer,
|
||||
type ResourceStatsCardContainerProps,
|
||||
ResourceStatsCardContent,
|
||||
type ResourceStatsCardContentProps,
|
||||
ResourceStatsCardDivider,
|
||||
type ResourceStatsCardDividerProps,
|
||||
ResourceStatsCardHeader,
|
||||
type ResourceStatsCardHeaderProps,
|
||||
type ResourceStatsCardProps,
|
||||
type StatItem,
|
||||
} from "./resource-stats-card";
|
||||
@@ -0,0 +1,13 @@
|
||||
export type { ResourceStatsCardProps } from "./resource-stats-card";
|
||||
export { ResourceStatsCard } from "./resource-stats-card";
|
||||
export type { ResourceStatsCardContainerProps } from "./resource-stats-card-container";
|
||||
export { ResourceStatsCardContainer } from "./resource-stats-card-container";
|
||||
export type {
|
||||
ResourceStatsCardContentProps,
|
||||
StatItem,
|
||||
} from "./resource-stats-card-content";
|
||||
export { ResourceStatsCardContent } from "./resource-stats-card-content";
|
||||
export type { ResourceStatsCardDividerProps } from "./resource-stats-card-divider";
|
||||
export { ResourceStatsCardDivider } from "./resource-stats-card-divider";
|
||||
export type { ResourceStatsCardHeaderProps } from "./resource-stats-card-header";
|
||||
export { ResourceStatsCardHeader } from "./resource-stats-card-header";
|
||||
@@ -0,0 +1,55 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const containerVariants = cva(
|
||||
[
|
||||
"flex",
|
||||
"rounded-[12px]",
|
||||
"border",
|
||||
"backdrop-blur-[46px]",
|
||||
"border-[rgba(38,38,38,0.70)]",
|
||||
"bg-[rgba(23,23,23,0.50)]",
|
||||
"dark:border-[rgba(38,38,38,0.70)]",
|
||||
"dark:bg-[rgba(23,23,23,0.50)]",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
padding: {
|
||||
sm: "px-3 py-2",
|
||||
md: "px-[19px] py-[9px]",
|
||||
lg: "px-6 py-3",
|
||||
none: "p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
padding: "md",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ResourceStatsCardContainerProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof containerVariants> {
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const ResourceStatsCardContainer = ({
|
||||
className,
|
||||
children,
|
||||
padding,
|
||||
ref,
|
||||
...props
|
||||
}: ResourceStatsCardContainerProps) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(containerVariants({ padding }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ResourceStatsCardContainer.displayName = "ResourceStatsCardContainer";
|
||||
@@ -0,0 +1,204 @@
|
||||
import { cva } from "class-variance-authority";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface StatItem {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const CardVariant = {
|
||||
default: "default",
|
||||
fail: "fail",
|
||||
pass: "pass",
|
||||
warning: "warning",
|
||||
info: "info",
|
||||
} as const;
|
||||
|
||||
export type CardVariant = (typeof CardVariant)[keyof typeof CardVariant];
|
||||
|
||||
const variantColors = {
|
||||
default: "#868994",
|
||||
fail: "#f54280",
|
||||
pass: "#4ade80",
|
||||
warning: "#fbbf24",
|
||||
info: "#60a5fa",
|
||||
} as const;
|
||||
|
||||
type BadgeVariant = keyof typeof variantColors;
|
||||
|
||||
const badgeVariants = cva(
|
||||
["flex", "items-center", "justify-center", "gap-0.5", "rounded-full"],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
[CardVariant.default]: "bg-[#535359]",
|
||||
[CardVariant.fail]: "bg-[#432232]",
|
||||
[CardVariant.pass]: "bg-[#204237]",
|
||||
[CardVariant.warning]: "bg-[#3d3520]",
|
||||
[CardVariant.info]: "bg-[#1e3a5f]",
|
||||
},
|
||||
size: {
|
||||
sm: "px-1 text-xs",
|
||||
md: "px-1.5 text-sm",
|
||||
lg: "px-2 text-base",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: CardVariant.fail,
|
||||
size: "md",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const badgeIconVariants = cva("", {
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-2.5 w-2.5",
|
||||
md: "h-3 w-3",
|
||||
lg: "h-4 w-4",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md",
|
||||
},
|
||||
});
|
||||
|
||||
const labelTextVariants = cva(
|
||||
"leading-6 font-semibold text-zinc-300 dark:text-zinc-300",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "text-xs",
|
||||
md: "text-sm",
|
||||
lg: "text-base",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const statIconVariants = cva("text-zinc-300 dark:text-zinc-300", {
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-2.5 w-2.5",
|
||||
md: "h-3 w-3",
|
||||
lg: "h-3.5 w-3.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md",
|
||||
},
|
||||
});
|
||||
|
||||
const statLabelVariants = cva(
|
||||
"leading-5 font-medium text-zinc-300 dark:text-zinc-300",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "text-xs",
|
||||
md: "text-sm",
|
||||
lg: "text-base",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ResourceStatsCardContentProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
badge: {
|
||||
icon: LucideIcon;
|
||||
count: number | string;
|
||||
variant?: CardVariant;
|
||||
};
|
||||
label: string;
|
||||
stats?: StatItem[];
|
||||
accentColor?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const ResourceStatsCardContent = ({
|
||||
badge,
|
||||
label,
|
||||
stats = [],
|
||||
accentColor,
|
||||
size = "md",
|
||||
className,
|
||||
ref,
|
||||
...props
|
||||
}: ResourceStatsCardContentProps) => {
|
||||
const BadgeIcon = badge.icon;
|
||||
const badgeVariant: BadgeVariant = badge.variant || "fail";
|
||||
|
||||
// Determine accent line color
|
||||
const lineColor = accentColor || variantColors[badgeVariant] || "#d4d4d8";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col gap-[5px]", className)}
|
||||
{...props}
|
||||
>
|
||||
{/* Badge and Label Row */}
|
||||
<div className="flex w-full items-center gap-1">
|
||||
{/* Badge */}
|
||||
<div className={cn(badgeVariants({ variant: badgeVariant, size }))}>
|
||||
<BadgeIcon
|
||||
className={badgeIconVariants({ size })}
|
||||
strokeWidth={2.5}
|
||||
style={{ color: variantColors[badgeVariant] }}
|
||||
/>
|
||||
<span
|
||||
className="leading-6 font-bold"
|
||||
style={{ color: variantColors[badgeVariant] }}
|
||||
>
|
||||
{badge.count}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<span className={labelTextVariants({ size })}>{label}</span>
|
||||
</div>
|
||||
|
||||
{/* Stats Section */}
|
||||
{stats.length > 0 && (
|
||||
<div className="flex w-full items-stretch gap-0">
|
||||
{/* Vertical Accent Line */}
|
||||
<div className="flex items-stretch px-3 py-1">
|
||||
<div
|
||||
className="w-px rounded-full"
|
||||
style={{ backgroundColor: lineColor }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats List */}
|
||||
<div className="flex flex-1 flex-col gap-0.5">
|
||||
{stats.map((stat, index) => {
|
||||
const StatIcon = stat.icon;
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-1">
|
||||
<StatIcon
|
||||
className={statIconVariants({ size })}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<span className={statLabelVariants({ size })}>
|
||||
{stat.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ResourceStatsCardContent.displayName = "ResourceStatsCardContent";
|
||||
@@ -0,0 +1,59 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const dividerVariants = cva("flex items-center justify-center", {
|
||||
variants: {
|
||||
spacing: {
|
||||
sm: "px-2",
|
||||
md: "px-[23px]",
|
||||
lg: "px-8",
|
||||
},
|
||||
orientation: {
|
||||
vertical: "h-full",
|
||||
horizontal: "w-full",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
spacing: "md",
|
||||
orientation: "vertical",
|
||||
},
|
||||
});
|
||||
|
||||
const lineVariants = cva("bg-[rgba(39,39,42,1)]", {
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: "h-full w-px",
|
||||
horizontal: "w-full h-px",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
});
|
||||
|
||||
export interface ResourceStatsCardDividerProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof dividerVariants> {
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const ResourceStatsCardDivider = ({
|
||||
className,
|
||||
spacing,
|
||||
orientation,
|
||||
ref,
|
||||
...props
|
||||
}: ResourceStatsCardDividerProps) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(dividerVariants({ spacing, orientation }), className)}
|
||||
{...props}
|
||||
>
|
||||
<div className={lineVariants({ orientation })} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ResourceStatsCardDivider.displayName = "ResourceStatsCardDivider";
|
||||
@@ -0,0 +1,103 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const headerVariants = cva("flex w-full items-center gap-1", {
|
||||
variants: {
|
||||
size: {
|
||||
sm: "",
|
||||
md: "",
|
||||
lg: "",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md",
|
||||
},
|
||||
});
|
||||
|
||||
const iconVariants = cva("text-zinc-300 dark:text-zinc-300", {
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-3.5 w-3.5",
|
||||
md: "h-4 w-4",
|
||||
lg: "h-5 w-5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md",
|
||||
},
|
||||
});
|
||||
|
||||
const titleVariants = cva(
|
||||
"leading-7 font-semibold text-zinc-300 dark:text-zinc-300",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "text-sm",
|
||||
md: "text-base",
|
||||
lg: "text-lg",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const countVariants = cva(
|
||||
"leading-4 font-normal text-zinc-300 dark:text-zinc-300",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "text-[9px]",
|
||||
md: "text-[10px]",
|
||||
lg: "text-xs",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ResourceStatsCardHeaderProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof headerVariants> {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
resourceCount?: number | string;
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const ResourceStatsCardHeader = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
resourceCount,
|
||||
size = "md",
|
||||
className,
|
||||
ref,
|
||||
...props
|
||||
}: ResourceStatsCardHeaderProps) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(headerVariants({ size }), className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-1">
|
||||
<Icon className={iconVariants({ size })} strokeWidth={2} />
|
||||
<span className={titleVariants({ size })}>{title}</span>
|
||||
</div>
|
||||
{resourceCount !== undefined && (
|
||||
<span className={countVariants({ size })}>
|
||||
{typeof resourceCount === "number"
|
||||
? `${resourceCount} Resources`
|
||||
: resourceCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ResourceStatsCardHeader.displayName = "ResourceStatsCardHeader";
|
||||
@@ -0,0 +1,164 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ResourceStatsCardContainer } from "./resource-stats-card-container";
|
||||
import type { StatItem } from "./resource-stats-card-content";
|
||||
import {
|
||||
CardVariant,
|
||||
ResourceStatsCardContent,
|
||||
} from "./resource-stats-card-content";
|
||||
import { ResourceStatsCardHeader } from "./resource-stats-card-header";
|
||||
|
||||
export type { StatItem };
|
||||
|
||||
// Todo: when the design system is ready, we must use the colors from the design system (semantic colors)
|
||||
// Variant styles using CVA for type safety and consistency
|
||||
// Colors are exact HEX values from Figma design system
|
||||
const cardVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
[CardVariant.default]: "",
|
||||
// Fail variant - rgba(67,34,50) from Figma
|
||||
[CardVariant.fail]:
|
||||
"border-[rgba(67,34,50,0.5)] bg-[rgba(67,34,50,0.2)] dark:border-[rgba(67,34,50,0.7)] dark:bg-[rgba(67,34,50,0.3)]",
|
||||
// Pass variant - rgba(32,66,55) from Figma
|
||||
[CardVariant.pass]:
|
||||
"border-[rgba(32,66,55,0.5)] bg-[rgba(32,66,55,0.2)] dark:border-[rgba(32,66,55,0.7)] dark:bg-[rgba(32,66,55,0.3)]",
|
||||
// Warning variant - rgba(61,53,32) from Figma
|
||||
[CardVariant.warning]:
|
||||
"border-[rgba(61,53,32,0.5)] bg-[rgba(61,53,32,0.2)] dark:border-[rgba(61,53,32,0.7)] dark:bg-[rgba(61,53,32,0.3)]",
|
||||
// Info variant - rgba(30,58,95) from Figma
|
||||
[CardVariant.info]:
|
||||
"border-[rgba(30,58,95,0.5)] bg-[rgba(30,58,95,0.2)] dark:border-[rgba(30,58,95,0.7)] dark:bg-[rgba(30,58,95,0.3)]",
|
||||
},
|
||||
size: {
|
||||
sm: "px-2 py-1.5 gap-1",
|
||||
md: "px-3 py-2 gap-2",
|
||||
lg: "px-4 py-3 gap-3",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: CardVariant.default,
|
||||
size: "md",
|
||||
},
|
||||
});
|
||||
|
||||
export interface ResourceStatsCardProps
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, "color">,
|
||||
VariantProps<typeof cardVariants> {
|
||||
// Optional header (icon + title + resource count)
|
||||
header?: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
resourceCount?: number | string;
|
||||
};
|
||||
|
||||
// Empty state message (when there's no data to display)
|
||||
emptyState?: {
|
||||
message: string;
|
||||
};
|
||||
|
||||
// Main badge (top section) - optional when using empty state
|
||||
badge?: {
|
||||
icon: LucideIcon;
|
||||
count: number | string;
|
||||
variant?: CardVariant;
|
||||
};
|
||||
|
||||
// Main label - optional when using empty state
|
||||
label?: string;
|
||||
|
||||
// Vertical accent line color (optional, auto-determined from variant)
|
||||
accentColor?: string;
|
||||
|
||||
// Sub-statistics array (flexible items)
|
||||
stats?: StatItem[];
|
||||
|
||||
// Render without container (no border, background, padding) - useful for composing multiple cards in a custom container
|
||||
containerless?: boolean;
|
||||
|
||||
// Ref for the root element
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const ResourceStatsCard = ({
|
||||
header,
|
||||
emptyState,
|
||||
badge,
|
||||
label,
|
||||
accentColor,
|
||||
stats = [],
|
||||
variant = CardVariant.default,
|
||||
size = "md",
|
||||
containerless = false,
|
||||
className,
|
||||
ref,
|
||||
...props
|
||||
}: ResourceStatsCardProps) => {
|
||||
// Resolve size to ensure it's not null (CVA can return null but we need a defined value)
|
||||
const resolvedSize = size || "md";
|
||||
|
||||
// If containerless, render without outer wrapper
|
||||
if (containerless) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col gap-[5px]", className)}
|
||||
{...props}
|
||||
>
|
||||
{header && <ResourceStatsCardHeader {...header} size={resolvedSize} />}
|
||||
{emptyState ? (
|
||||
<div className="flex h-[51px] w-full flex-col items-center justify-center">
|
||||
<p className="text-center text-sm leading-5 font-medium text-zinc-300 dark:text-zinc-300">
|
||||
{emptyState.message}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
badge &&
|
||||
label && (
|
||||
<ResourceStatsCardContent
|
||||
badge={badge}
|
||||
label={label}
|
||||
stats={stats}
|
||||
accentColor={accentColor}
|
||||
size={resolvedSize}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, render with container
|
||||
return (
|
||||
<ResourceStatsCardContainer
|
||||
ref={ref}
|
||||
className={cn(cardVariants({ variant, size }), "flex-col", className)}
|
||||
{...props}
|
||||
>
|
||||
{header && <ResourceStatsCardHeader {...header} size={resolvedSize} />}
|
||||
{emptyState ? (
|
||||
<div className="flex h-[51px] w-full flex-col items-center justify-center">
|
||||
<p className="text-center text-sm leading-5 font-medium text-zinc-300 dark:text-zinc-300">
|
||||
{emptyState.message}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
badge &&
|
||||
label && (
|
||||
<ResourceStatsCardContent
|
||||
badge={badge}
|
||||
label={label}
|
||||
stats={stats}
|
||||
accentColor={accentColor}
|
||||
size={resolvedSize}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</ResourceStatsCardContainer>
|
||||
);
|
||||
};
|
||||
|
||||
ResourceStatsCard.displayName = "ResourceStatsCard";
|
||||
@@ -399,6 +399,14 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-09-10T11:50:17.548Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "tw-animate-css",
|
||||
"from": "1.4.0",
|
||||
"to": "1.4.0",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-15T07:57:13.225Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "uuid",
|
||||
|
||||
Generated
+382
-6
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -69,10 +69,10 @@
|
||||
"recharts": "2.15.4",
|
||||
"rss-parser": "3.13.0",
|
||||
"server-only": "0.0.1",
|
||||
"shadcn": "3.2.1",
|
||||
"sharp": "0.33.5",
|
||||
"tailwind-merge": "3.3.1",
|
||||
"tailwindcss-animate": "1.0.7",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"uuid": "11.1.0",
|
||||
"zod": "4.1.11",
|
||||
"zustand": "5.0.8"
|
||||
@@ -105,6 +105,7 @@
|
||||
"postcss": "8.4.38",
|
||||
"prettier": "3.6.2",
|
||||
"prettier-plugin-tailwindcss": "0.6.14",
|
||||
"shadcn": "3.4.1",
|
||||
"tailwind-variants": "0.1.20",
|
||||
"tailwindcss": "4.1.13",
|
||||
"typescript": "5.5.4"
|
||||
|
||||
Reference in New Issue
Block a user