193 lines
6.3 KiB
TypeScript
193 lines
6.3 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: 12, name: "Observatory" },
|
|
{ id: 16, name: "Fossil tracker" },
|
|
{ id: 6, name: "Audiobookshelf" },
|
|
{ id: 7, name: "Woodpecker CI" },
|
|
{ id: 8, name: "Forgejo Git" },
|
|
{ id: 9, name: "Server dashboard" },
|
|
{ id: 3, name: "Dozzle" },
|
|
{ id: 13, name: "Surf hub" },
|
|
{ id: 11, name: "Anime list" },
|
|
{ id: 5, name: "Wiki" },
|
|
{ id: 14, name: "Paperless" },
|
|
];
|
|
|
|
const ITEMS_PER_PAGE = 6;
|
|
const INTERVAL_TIME = 2500;
|
|
|
|
export default function MonitorCard({ isHovered }: { isHovered: boolean }) {
|
|
const [page, setPage] = useState(0);
|
|
const totalPages = Math.ceil(MONITORS.length / ITEMS_PER_PAGE);
|
|
|
|
useEffect(() => {
|
|
let interval: NodeJS.Timeout;
|
|
|
|
if (isHovered) {
|
|
// Start rotating pages only when hovered
|
|
interval = setInterval(() => {
|
|
setPage((p) => (p + 1) % totalPages);
|
|
}, INTERVAL_TIME);
|
|
} else {
|
|
// Defer state reset to avoid "cascading render" error
|
|
// and allow the fade-out animation to play smoothly
|
|
const timeout = setTimeout(() => {
|
|
setPage(0);
|
|
}, 300);
|
|
return () => clearTimeout(timeout);
|
|
}
|
|
|
|
return () => {
|
|
if (interval) clearInterval(interval);
|
|
};
|
|
}, [isHovered, totalPages]);
|
|
|
|
return (
|
|
<div className="relative w-full h-full min-h-[180px] flex flex-col justify-center">
|
|
{/* --- DEFAULT VIEW --- */}
|
|
<div
|
|
className={`transition-opacity duration-300 ${
|
|
isHovered ? "opacity-0 pointer-events-none" : "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 --- */}
|
|
<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"
|
|
}`}
|
|
>
|
|
{/* HEADER WITH SYNCED TIMER */}
|
|
<div className="flex items-center justify-between mb-3 group/header">
|
|
<h4 className="text-[10px] font-mono text-neutral-500 uppercase tracking-[0.2em] flex items-center gap-2 group-hover:text-blue-400 transition-colors">
|
|
Explore Systems
|
|
<motion.span
|
|
animate={{ x: [0, 4, 0] }}
|
|
transition={{
|
|
duration: 1.5,
|
|
repeat: Infinity,
|
|
ease: "easeInOut",
|
|
}}
|
|
className="inline-block"
|
|
>
|
|
→
|
|
</motion.span>
|
|
</h4>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative w-12 h-[2.5px] bg-neutral-800 rounded-full overflow-hidden">
|
|
<motion.div
|
|
key={`${isHovered}-${page}`}
|
|
initial={{ width: "0%" }}
|
|
animate={isHovered ? { width: "100%" } : { width: "0%" }}
|
|
transition={{
|
|
duration: isHovered ? INTERVAL_TIME / 1000 : 0,
|
|
ease: "linear",
|
|
}}
|
|
className="h-full bg-blue-500/50" // Changed to blue to match Link intent
|
|
/>
|
|
</div>
|
|
<span className="text-[9px] font-mono text-neutral-600">
|
|
{String(page + 1).padStart(2, "0")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-hidden">
|
|
<RegistrySlider page={page} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RegistrySlider({ page }: { page: number }) {
|
|
const currentItems = MONITORS.slice(
|
|
page * ITEMS_PER_PAGE,
|
|
(page + 1) * ITEMS_PER_PAGE,
|
|
);
|
|
|
|
return (
|
|
<div className="relative h-full">
|
|
<AnimatePresence mode="popLayout">
|
|
<motion.div
|
|
key={page}
|
|
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}
|
|
height={20}
|
|
className="h-5 w-auto"
|
|
alt="System Status"
|
|
unoptimized
|
|
/>
|
|
<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>
|
|
);
|
|
}
|