diff --git a/web/src/components/TargetCard.tsx b/web/src/components/TargetCard.tsx new file mode 100644 index 0000000..2e0d870 --- /dev/null +++ b/web/src/components/TargetCard.tsx @@ -0,0 +1,82 @@ +import { memo } from "react"; +import { motion } from "motion/react"; +import type { LiveTarget } from "../lib/types"; +import { StatusRing } from "./StatusRing"; +import { fmtInterval } from "../lib/format"; +import { springs, STATE_COLOR, useUI } from "../lib/ui"; + +interface TargetCardProps { + target: LiveTarget; + index: number; + onSelect: (id: string) => void; +} + +export const TargetCard = memo(function TargetCard({ + target, + index, + onSelect, +}: TargetCardProps) { + const { t, lang, window, reduced } = useUI(); + const uptime = window === "7d" ? target.uptime_7d : target.uptime_24h; + const delay = 0.1 + Math.min(index, 12) * 0.045; + + return ( + onSelect(target.id)} + className="group relative flex flex-col items-center text-center px-4 pt-5 pb-4 rounded-md border border-ink-800 bg-ink-900/35 cursor-pointer overflow-hidden" + initial={reduced ? false : { opacity: 0, y: 18 }} + animate={{ opacity: 1, y: 0 }} + exit={reduced ? undefined : { opacity: 0, scale: 0.96 }} + transition={reduced ? { duration: 0 } : { ...springs.gentle, delay }} + whileHover={ + reduced + ? undefined + : { y: -3, borderColor: "var(--color-ink-700)" } + } + whileTap={reduced ? undefined : { scale: 0.985 }} + aria-label={`${target.name} — ${t.state[target.state]}`} + > + {/* faint state wash at the top edge */} + + + + + + +

+ {target.name} +

+

+ {target.endpoint} +

+ +
+ + + {t.state[target.state]} + + · + + {target.type} + + · + + {t.interval} {fmtInterval(lang, target.interval_sec)} + +
+
+ ); +});