Files
homelab-optimized/dashboard/ui/components/stat-card.tsx
Gitea Mirror Bot fb00a325d1
Some checks failed
Documentation / Build Docusaurus (push) Failing after 5m14s
Documentation / Deploy to GitHub Pages (push) Has been skipped
Sanitized mirror from private repository - 2026-04-18 11:19:59 UTC
2026-04-18 11:19:59 +00:00

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>
);
}