feat(ui): new card components and derivates for overview (#8921)

Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
This commit is contained in:
Alejandro Bailo
2025-10-20 16:49:09 +02:00
committed by GitHub
parent 0fa9e2da6c
commit 5e85ef5835
13 changed files with 1181 additions and 7 deletions
+21
View File
@@ -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"
}
+57
View File
@@ -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)
+92
View File
@@ -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,
};
+21
View File
@@ -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";
+8
View File
@@ -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",
+382 -6
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -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"