173 lines
6.4 KiB
TypeScript
173 lines
6.4 KiB
TypeScript
"use client";
|
|
import React from 'react';
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import { EventCardProps } from '@/types/space';
|
|
import { useRouter } from 'next/navigation';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { LucideProps } from 'lucide-react';
|
|
|
|
export default function EventCard({ id, title, targetDate, endDate, icon }: EventCardProps) {
|
|
const [isLive, setIsLive] = useState(false);
|
|
const router = useRouter();
|
|
const [timeLeft, setTimeLeft] = useState({ d: "00", h: "00", m: "00", s: "00" });
|
|
|
|
const lastRefreshedRef = useRef<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
const updateTimer = () => {
|
|
if (!targetDate || !endDate) return;
|
|
const now = new Date();
|
|
const timeToStart = targetDate.getTime() - now.getTime();
|
|
const timeToEnd = endDate.getTime() - now.getTime();
|
|
|
|
if (timeToStart <= 0 && timeToEnd > 0) {
|
|
if (!isLive) setIsLive(true);
|
|
return;
|
|
}
|
|
|
|
if (timeToEnd <= 0) {
|
|
if (isLive) setIsLive(false);
|
|
|
|
const passId = endDate.toISOString();
|
|
|
|
if (lastRefreshedRef.current !== passId) {
|
|
|
|
const isStaleDataFromServer = endDate.getTime() < (new Date().getTime() - 5000);
|
|
|
|
if (!isStaleDataFromServer) {
|
|
console.log("🛰️ Pass complete. Syncing with Mission Control...");
|
|
lastRefreshedRef.current = passId;
|
|
router.refresh();
|
|
} else {
|
|
console.warn("⚠️ Server returned stale ISS data. Standing by for worker update...");
|
|
lastRefreshedRef.current = passId;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const distance = timeToStart;
|
|
const d = Math.floor(distance / (1000 * 60 * 60 * 24));
|
|
const h = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
|
const m = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
|
|
const s = Math.floor((distance % (1000 * 60)) / 1000);
|
|
|
|
setTimeLeft({
|
|
d: d.toString().padStart(2, '0'),
|
|
h: h.toString().padStart(2, '0'),
|
|
m: m.toString().padStart(2, '0'),
|
|
s: s.toString().padStart(2, '0')
|
|
});
|
|
};
|
|
|
|
const interval = setInterval(updateTimer, 1000);
|
|
updateTimer();
|
|
|
|
return () => clearInterval(interval);
|
|
}, [targetDate, endDate, isLive, router]);
|
|
|
|
if (!targetDate) {
|
|
return (
|
|
<div className="relative p-6 rounded-xl border border-slate-800 bg-slate-900/20 backdrop-blur-md overflow-hidden opacity-60">
|
|
<div className="flex items-center gap-4 mb-4">
|
|
<div className="p-2 bg-slate-700/30 rounded-lg text-slate-500">
|
|
{icon}
|
|
</div>
|
|
<h3 className="text-slate-500 font-mono tracking-widest uppercase text-xs">
|
|
{title}
|
|
</h3>
|
|
</div>
|
|
<div className="py-2">
|
|
<div className="h-8 w-48 bg-slate-800 animate-pulse rounded" />
|
|
<p className="text-[10px] text-slate-600 mt-4 font-mono uppercase tracking-widest italic">
|
|
Waiting for orbital telemetry...
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={`relative p-6 rounded-xl border transition-all duration-500 overflow-hidden h-full ${isLive ? 'border-blue-500 bg-blue-900/20 shadow-[0_0_20px_rgba(59,130,246,0.3)]' : 'border-slate-800 bg-slate-900/40'}`}>
|
|
|
|
{isLive && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: [0.1, 0.3, 0.1] }}
|
|
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
|
|
className="absolute inset-0 bg-blue-500 pointer-events-none"
|
|
/>
|
|
)}
|
|
|
|
<div className="relative z-10 h-[120px] flex flex-col justify-between">
|
|
{isLive ? (
|
|
<div className="flex flex-col items-center justify-center h-full relative z-10">
|
|
<motion.div
|
|
animate={{
|
|
scale: [1, 1.3, 1],
|
|
filter: ["drop-shadow(0 0 0px #fbbf24)", "drop-shadow(0 0 8px #fbbf24)", "drop-shadow(0 0 0px #fbbf24)"]
|
|
}}
|
|
transition={{ duration: 3, repeat: Infinity }}
|
|
className="text-yellow-400 mb-3"
|
|
>
|
|
{React.isValidElement(icon)
|
|
? React.cloneElement(icon as React.ReactElement<LucideProps>, { size: 35 })
|
|
: icon}
|
|
</motion.div>
|
|
|
|
<h3 className="text-lg font-bold text-white text-center leading-tight">
|
|
{id === 'iss' ? 'Look Up!' : 'Event in Progress'}
|
|
</h3>
|
|
|
|
<p className="text-blue-200 text-[14px] text-center mt-2 max-w-[220px] leading-tight opacity-90">
|
|
{id === 'iss'
|
|
? 'The ISS is directly above. Give them a wave! 👋'
|
|
: `The ${title} is occurring right now.`}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center gap-4">
|
|
<div className="p-2 bg-blue-500/10 rounded-lg text-blue-400">{icon}</div>
|
|
<h3 className="text-slate-300 font-mono tracking-widest uppercase text-[10px] truncate">
|
|
{title}
|
|
</h3>
|
|
</div>
|
|
|
|
<div className="text-4xl font-mono text-white flex items-baseline gap-2 py-2">
|
|
{parseInt(timeLeft.d) >= 2 ? (
|
|
<>
|
|
<span>{timeLeft.d}</span>
|
|
<span className="text-slate-500 text-xl lowercase">Days</span>
|
|
</>
|
|
) : (
|
|
<div className="flex gap-1">
|
|
<span>{timeLeft.h}</span><span className="text-slate-500">:</span>
|
|
<span>{timeLeft.m}</span><span className="text-slate-500">:</span>
|
|
<div className="w-[1ch] relative">
|
|
<AnimatePresence mode="popLayout">
|
|
<motion.span
|
|
key={timeLeft.s}
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -10 }}
|
|
transition={{ duration: 0.3 }}
|
|
className="absolute"
|
|
>
|
|
{timeLeft.s}
|
|
</motion.span>
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<p className="text-[10px] text-slate-500 font-mono uppercase tracking-widest leading-none">
|
|
{id === 'iss' ? "T-Minus to Horizon" : id === 'launch' ? "T-Minus to Ignition" : id === 'moon' ? "Until Lunar Phase" : "Days to Peak Phase"}
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
} |