portfolio/components/MonitorCard.tsx
2026-02-02 13:53:49 +01:00

144 lines
5 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Activity } from "lucide-react";
import Image from "next/image";
const MONITORS = [
{ id: 2, name: "Datasaur" },
{ id: 6, name: "Audiobookshelf" },
{ id: 7, name: "Woodpecker CI" },
{ id: 8, name: "Forgejo Git" },
{ id: 9, name: "Server dashboard" },
{ id: 10, name: "Ratoong" },
{ id: 3, name: "Dozzle" },
{ id: 12, name: "Observatory" },
{ id: 13, name: "Surf hub" },
{ id: 11, name: "Anime list" },
{ id: 5, name: "Wiki" },
{ id: 4, name: "Watchtower" },
];
export default function MonitorCard({ isHovered }: { isHovered: boolean }) {
return (
<div className="relative w-full h-full min-h-[180px] flex flex-col justify-center">
{/* DEFAULT VIEW: Always rendered, opacity controlled by parent hover */}
<div
className={`transition-opacity duration-300 ${isHovered ? "opacity-0" : "opacity-100"}`}
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse shadow-[0_0_8px_rgba(34,197,94,0.6)]" />
<span className="font-medium text-white tracking-tight">
Hetzner Node-01
</span>
</div>
<p className="text-[10px] font-mono text-neutral-500">
SYS_STATUS: ONLINE
</p>
</div>
<Activity className="text-neutral-800 w-10 h-10 -mt-1" />
</div>
<div className="mt-8 grid grid-cols-2 gap-4 border-t border-neutral-800/50 pt-6">
<div>
<p className="text-[10px] font-mono text-neutral-600 uppercase">
Architecture
</p>
<p className="text-xs text-neutral-400">linux/amd64</p>
</div>
<div>
<p className="text-[10px] font-mono text-neutral-600 uppercase">
Provider
</p>
<p className="text-xs text-neutral-400">Hetzner Cloud</p>
</div>
</div>
</div>
{/* REGISTRY VIEW: Only "Active" when hovered */}
<div
className={`absolute inset-0 transition-all duration-300 flex flex-col ${
isHovered
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-4 pointer-events-none"
}`}
>
<h4 className="text-[10px] font-mono text-neutral-500 uppercase tracking-[0.2em] mb-3">
Service Registry
</h4>
<div className="flex-1 overflow-hidden">
{/* We pass isHovered here to start/stop the timer inside a separate lifecycle */}
<RegistrySlider isHovered={isHovered} />
</div>
</div>
</div>
);
}
function RegistrySlider({ isHovered }: { isHovered: boolean }) {
const [page, setPage] = useState(0);
const totalPages = Math.ceil(MONITORS.length / 6);
useEffect(() => {
if (!isHovered) {
const timeout = setTimeout(() => setPage(0), 300);
return () => clearTimeout(timeout);
}
const interval = setInterval(() => {
setPage((p) => (p + 1) % totalPages);
}, 4000);
return () => clearInterval(interval);
}, [isHovered, totalPages]);
const currentItems = MONITORS.slice(page * 6, (page + 1) * 6);
return (
<div className="relative h-full">
<AnimatePresence mode="popLayout">
<motion.div
key={page} // Key change: this triggers the animation on page change
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
className="grid grid-cols-1 gap-1.5 w-full"
>
{currentItems.map((m) => (
<div
key={m.id}
className="flex items-center justify-between bg-neutral-800/40 p-1.5 px-3 rounded-lg border border-neutral-700/30"
>
<span className="text-[11px] font-medium text-neutral-300 truncate mr-2">
{m.name}
</span>
<div className="flex gap-1 shrink-0 scale-75 origin-right">
<Image
src={`https://status.georgew.dev/api/badge/${m.id}/status`}
width={60} // Set a base width
height={20} // Set a base height
className="h-5 w-auto" // Keep your CSS for sizing
alt="System Status"
unoptimized // Essential for live status badges
/>
<Image
src={`https://status.georgew.dev/api/badge/${m.id}/avg-response/24`}
width={80}
height={20}
className="h-5 w-auto opacity-60"
alt="Average Response Time"
unoptimized
/>
</div>
</div>
))}
</motion.div>
</AnimatePresence>
</div>
);
}