135 lines
4.2 KiB
TypeScript
135 lines
4.2 KiB
TypeScript
import { Card, CardContent } from "@/components/ui/card";
|
|
|
|
type StatColor = "blue" | "green" | "violet" | "amber" | "emerald";
|
|
|
|
const gradientMap: Record<StatColor, string> = {
|
|
blue: "from-blue-300 to-blue-500",
|
|
green: "from-green-300 to-green-500",
|
|
violet: "from-violet-300 to-violet-500",
|
|
amber: "from-amber-300 to-amber-500",
|
|
emerald: "from-emerald-300 to-emerald-500",
|
|
};
|
|
|
|
const accentLineMap: Record<StatColor, string> = {
|
|
blue: "#3b82f6",
|
|
green: "#22c55e",
|
|
violet: "#8b5cf6",
|
|
amber: "#f59e0b",
|
|
emerald: "#10b981",
|
|
};
|
|
|
|
const strokeColorMap: Record<StatColor, [string, string]> = {
|
|
blue: ["#93c5fd", "#3b82f6"],
|
|
green: ["#86efac", "#22c55e"],
|
|
violet: ["#c4b5fd", "#8b5cf6"],
|
|
amber: ["#fcd34d", "#f59e0b"],
|
|
emerald: ["#6ee7b7", "#10b981"],
|
|
};
|
|
|
|
interface StatCardProps {
|
|
label: string;
|
|
value: string | number;
|
|
sub?: React.ReactNode;
|
|
color?: StatColor;
|
|
gauge?: number; // 0-100 percentage for ring gauge
|
|
}
|
|
|
|
function colorizeSubText(sub: React.ReactNode): React.ReactNode {
|
|
if (typeof sub !== "string") return sub;
|
|
const keywords: [RegExp, string][] = [
|
|
[/\b(running|online|healthy|clean|clear|all good|ok)\b/gi, "text-green-400"],
|
|
[/\b(error|alert|fail|errors detected)\b/gi, "text-red-400"],
|
|
];
|
|
for (const [pattern, cls] of keywords) {
|
|
if (pattern.test(sub)) {
|
|
return (
|
|
<>
|
|
{sub.split(pattern).map((part, i) =>
|
|
pattern.test(part) ? (
|
|
<span key={i} className={cls}>{part}</span>
|
|
) : (
|
|
<span key={i}>{part}</span>
|
|
)
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
}
|
|
return sub;
|
|
}
|
|
|
|
function GaugeRing({ pct, color, size = 64, stroke = 4 }: { pct: number; color: StatColor; size?: number; stroke?: number }) {
|
|
const radius = (size - stroke) / 2;
|
|
const circumference = 2 * Math.PI * radius;
|
|
const offset = circumference - (pct / 100) * circumference;
|
|
const gradientId = `gauge-grad-${color}`;
|
|
const [stopStart, stopEnd] = strokeColorMap[color] ?? ["#93c5fd", "#3b82f6"];
|
|
|
|
return (
|
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="-rotate-90">
|
|
<defs>
|
|
<linearGradient id={gradientId}>
|
|
<stop offset="0%" stopColor={stopStart} />
|
|
<stop offset="100%" stopColor={stopEnd} />
|
|
</linearGradient>
|
|
</defs>
|
|
<circle
|
|
className="gauge-track"
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
strokeWidth={stroke}
|
|
/>
|
|
<circle
|
|
className="gauge-fill"
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
strokeWidth={stroke}
|
|
stroke={`url(#${gradientId})`}
|
|
strokeDasharray={circumference}
|
|
strokeDashoffset={offset}
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export function StatCard({ label, value, sub, color = "blue", gauge }: StatCardProps) {
|
|
const hasGauge = gauge != null && gauge >= 0;
|
|
const accent = accentLineMap[color];
|
|
|
|
return (
|
|
<Card className="card-hover-lift overflow-hidden relative group">
|
|
{/* Top accent line */}
|
|
<span
|
|
className="absolute top-0 left-[20%] right-[20%] h-px"
|
|
style={{
|
|
background: `linear-gradient(90deg, transparent, ${accent}, transparent)`,
|
|
}}
|
|
/>
|
|
<CardContent className="pt-5 pb-4 px-4 relative flex flex-col items-center justify-center text-center min-h-[110px]">
|
|
{hasGauge ? (
|
|
<div className="relative flex items-center justify-center mb-1" style={{ width: 64, height: 64 }}>
|
|
<GaugeRing pct={gauge} color={color} />
|
|
<span
|
|
className={`absolute inset-0 flex items-center justify-center text-xl font-bold bg-gradient-to-b ${gradientMap[color]} bg-clip-text text-transparent tabular-nums-transition`}
|
|
>
|
|
{value}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<p
|
|
className={`text-3xl font-bold bg-gradient-to-b ${gradientMap[color]} bg-clip-text text-transparent tabular-nums-transition mb-1`}
|
|
>
|
|
{value}
|
|
</p>
|
|
)}
|
|
<p className="text-xs uppercase tracking-wider text-muted-foreground font-medium">
|
|
{label}
|
|
</p>
|
|
{sub && <div className="mt-0.5 text-sm text-muted-foreground/70">{colorizeSubText(sub)}</div>}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|