added connection status
This commit is contained in:
parent
53a3a5d44f
commit
f2183f0d15
|
|
@ -37,12 +37,20 @@ export const handleRequest = async (
|
||||||
!message ||
|
!message ||
|
||||||
!(typeof message === "object") ||
|
!(typeof message === "object") ||
|
||||||
!("type" in message) ||
|
!("type" in message) ||
|
||||||
!("payload" in message) ||
|
|
||||||
!("id" in message)
|
!("id" in message)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
const { type, payload, id } = message;
|
const { type, id } = message;
|
||||||
if (!(typeof type === "string")) return;
|
if (!(typeof type === "string")) return;
|
||||||
|
|
||||||
|
// Handle ping message
|
||||||
|
if (type === 'ping') {
|
||||||
|
ws.send(JSON.stringify({ type: 'pong', id }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!("payload" in message)) return;
|
||||||
|
const { payload } = message;
|
||||||
const [controllerName, action] = type.split(".");
|
const [controllerName, action] = type.split(".");
|
||||||
if (!(controllerName in controllers)) return;
|
if (!(controllerName in controllers)) return;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,6 @@ interface LootboxResult {
|
||||||
lootbox: string;
|
lootbox: string;
|
||||||
}
|
}
|
||||||
export const lootboxResultAtom = atom<LootboxResult | undefined>();
|
export const lootboxResultAtom = atom<LootboxResult | undefined>();
|
||||||
|
|
||||||
|
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
|
||||||
|
export const connectionStatusAtom = atom<ConnectionStatus>('connecting');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { connectionStatusAtom, type ConnectionStatus as ConnectionStatusType } from "../atoms";
|
||||||
|
import { Wifi, WifiOff } from "lucide-react";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
|
||||||
|
const statusConfig: Record<ConnectionStatusType, { color: string; icon: React.ReactNode; label: string }> = {
|
||||||
|
connecting: { color: "text-yellow-400", icon: <Wifi className="w-3 h-3" />, label: "Connecting..." },
|
||||||
|
connected: { color: "text-green-400", icon: <Wifi className="w-3 h-3" />, label: "Connected" },
|
||||||
|
disconnected: { color: "text-red-400", icon: <WifiOff className="w-3 h-3" />, label: "Disconnected" },
|
||||||
|
reconnecting: { color: "text-yellow-400", icon: <Wifi className="w-3 h-3" />, label: "Reconnecting..." },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConnectionStatus = () => {
|
||||||
|
const [status] = useAtom(connectionStatusAtom);
|
||||||
|
const config = statusConfig[status];
|
||||||
|
|
||||||
|
// Only show when not connected
|
||||||
|
const shouldShow = status !== 'connected';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{shouldShow && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
className={`flex items-center gap-1.5 text-xs ${config.color} bg-white/5 px-2 py-1 rounded-md backdrop-blur-sm`}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={status === 'connecting' || status === 'reconnecting' ? { rotate: 360 } : {}}
|
||||||
|
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||||
|
>
|
||||||
|
{config.icon}
|
||||||
|
</motion.div>
|
||||||
|
<span className="font-medium">{config.label}</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -15,6 +15,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { loginTokenAtom } from "../atoms";
|
import { loginTokenAtom } from "../atoms";
|
||||||
import Gems from "./Gems";
|
import Gems from "./Gems";
|
||||||
|
import { ConnectionStatus } from "./ConnectionStatus";
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
|
|
@ -28,7 +29,8 @@ const Header = () => {
|
||||||
const { data: gems } = useWSQuery("user.getOwnGems", null);
|
const { data: gems } = useWSQuery("user.getOwnGems", null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex gap-4">
|
<div className="w-full flex gap-4 items-center">
|
||||||
|
<ConnectionStatus />
|
||||||
<div className="grow" />
|
<div className="grow" />
|
||||||
|
|
||||||
{username ? (
|
{username ? (
|
||||||
|
|
|
||||||
|
|
@ -9,19 +9,22 @@ import {
|
||||||
import PastMatch from "../../components/PastMatch";
|
import PastMatch from "../../components/PastMatch";
|
||||||
import GemsIcon from "../../components/GemIcon";
|
import GemsIcon from "../../components/GemIcon";
|
||||||
|
|
||||||
const TouchTooltip = ({ children, content, date, games }: {
|
const TouchTooltip = ({
|
||||||
|
children,
|
||||||
|
date,
|
||||||
|
games,
|
||||||
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
content: React.ReactNode;
|
|
||||||
date: string;
|
date: string;
|
||||||
games: number;
|
games: number;
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isTouchDevice, setIsTouchDevice] = useState(false);
|
const [isTouchDevice, setIsTouchDevice] = useState(false);
|
||||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
const timeoutRef = useRef<Timer>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Detect if device supports touch
|
// Detect if device supports touch
|
||||||
setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0);
|
setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleTouch = () => {
|
const handleTouch = () => {
|
||||||
|
|
@ -42,7 +45,11 @@ const TouchTooltip = ({ children, content, date, games }: {
|
||||||
if (isTouchDevice) {
|
if (isTouchDevice) {
|
||||||
return (
|
return (
|
||||||
<Tooltip open={isOpen}>
|
<Tooltip open={isOpen}>
|
||||||
<TooltipTrigger asChild onClick={handleTouch} onTouchStart={handleTouch}>
|
<TooltipTrigger
|
||||||
|
asChild
|
||||||
|
onClick={handleTouch}
|
||||||
|
onTouchStart={handleTouch}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
|
|
@ -56,9 +63,7 @@ const TouchTooltip = ({ children, content, date, games }: {
|
||||||
// For non-touch devices, use default hover behavior
|
// For non-touch devices, use default hover behavior
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||||
{children}
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>{date}</p>
|
<p>{date}</p>
|
||||||
<p>{games} Games Played</p>
|
<p>{games} Games Played</p>
|
||||||
|
|
@ -90,7 +95,6 @@ const Profile: React.FC = () => {
|
||||||
!!username,
|
!!username,
|
||||||
);
|
);
|
||||||
const now = useMemo(() => dayjs(), []);
|
const now = useMemo(() => dayjs(), []);
|
||||||
const startDate = useMemo(() => now.subtract(availableWeeks, "weeks").startOf("week"), [now, availableWeeks]);
|
|
||||||
const maxHeat = heatmap ? Math.max(...heatmap) : 0;
|
const maxHeat = heatmap ? Math.max(...heatmap) : 0;
|
||||||
|
|
||||||
// Calculate available space and adjust weeks accordingly
|
// Calculate available space and adjust weeks accordingly
|
||||||
|
|
@ -120,11 +124,11 @@ const Profile: React.FC = () => {
|
||||||
// Use setTimeout to ensure DOM is fully rendered
|
// Use setTimeout to ensure DOM is fully rendered
|
||||||
const timer = setTimeout(calculateAvailableWeeks, 0);
|
const timer = setTimeout(calculateAvailableWeeks, 0);
|
||||||
|
|
||||||
window.addEventListener('resize', calculateAvailableWeeks);
|
window.addEventListener("resize", calculateAvailableWeeks);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
window.removeEventListener('resize', calculateAvailableWeeks);
|
window.removeEventListener("resize", calculateAvailableWeeks);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -153,7 +157,9 @@ const Profile: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<div className="grid md:[grid-template-columns:_2fr_3fr] gap-6 px-2 sm:px-0">
|
<div className="grid md:[grid-template-columns:_2fr_3fr] gap-6 px-2 sm:px-0">
|
||||||
<div className="mx-4 my-8 md:m-8 text-white flex flex-col sm:flex-row self-center">
|
<div className="mx-4 my-8 md:m-8 text-white flex flex-col sm:flex-row self-center">
|
||||||
<div className="p-2 flex items-center text-2xl mb-2 sm:mb-0">{username}</div>
|
<div className="p-2 flex items-center text-2xl mb-2 sm:mb-0">
|
||||||
|
{username}
|
||||||
|
</div>
|
||||||
<div className="border-l-0 sm:border-l-white sm:border-l p-2 text-lg">
|
<div className="border-l-0 sm:border-l-white sm:border-l p-2 text-lg">
|
||||||
<p>Total Games: {profile?.totalGames}</p>
|
<p>Total Games: {profile?.totalGames}</p>
|
||||||
<p>Highest Stage: {profile?.highestStage}</p>
|
<p>Highest Stage: {profile?.highestStage}</p>
|
||||||
|
|
@ -178,21 +184,20 @@ const Profile: React.FC = () => {
|
||||||
<div className="overflow-x-auto" ref={containerRef}>
|
<div className="overflow-x-auto" ref={containerRef}>
|
||||||
<div className="flex gap-1 sm:gap-2 min-w-fit">
|
<div className="flex gap-1 sm:gap-2 min-w-fit">
|
||||||
{Array.from({ length: availableWeeks }).map((_, w) => (
|
{Array.from({ length: availableWeeks }).map((_, w) => (
|
||||||
<div key={w} className="w-4 sm:w-6 flex gap-1 sm:gap-2 flex-col">
|
<div
|
||||||
|
key={w}
|
||||||
|
className="w-4 sm:w-6 flex gap-1 sm:gap-2 flex-col"
|
||||||
|
>
|
||||||
{Array.from({ length: 7 }).map((_, d) => {
|
{Array.from({ length: 7 }).map((_, d) => {
|
||||||
const index = (heatmap.length - (availableWeeks * 7)) + (w * 7 + d);
|
const index =
|
||||||
|
heatmap.length - availableWeeks * 7 + (w * 7 + d);
|
||||||
if (index < 0 || index >= heatmap.length) return;
|
if (index < 0 || index >= heatmap.length) return;
|
||||||
const date = now
|
const date = now
|
||||||
.clone()
|
.clone()
|
||||||
.subtract((availableWeeks * 7) - 1 - (w * 7 + d), "days")
|
.subtract(availableWeeks * 7 - 1 - (w * 7 + d), "days")
|
||||||
.format("DD/MM/YYYY");
|
.format("DD/MM/YYYY");
|
||||||
return (
|
return (
|
||||||
<TouchTooltip
|
<TouchTooltip key={d} date={date} games={heatmap[index]}>
|
||||||
key={d}
|
|
||||||
date={date}
|
|
||||||
games={heatmap[index]}
|
|
||||||
content={null}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
className="w-3 h-3 sm:w-5 sm:h-5 border border-white touch-manipulation"
|
className="w-3 h-3 sm:w-5 sm:h-5 border border-white touch-manipulation"
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import type { Routes } from "../backend/router";
|
import type { Routes } from "../backend/router";
|
||||||
import type { Events } from "../shared/events";
|
import type { Events } from "../shared/events";
|
||||||
import { queryClient } from "./queryClient";
|
import { queryClient } from "./queryClient";
|
||||||
|
import { connectionStatusAtom } from "./atoms";
|
||||||
|
import { getDefaultStore } from "jotai";
|
||||||
|
|
||||||
const connectionString = import.meta.env.DEV
|
const connectionString = import.meta.env.DEV
|
||||||
? "ws://localhost:8072/ws"
|
? "ws://localhost:8072/ws"
|
||||||
|
|
@ -21,11 +23,43 @@ const emitMessage = (event: MessageEvent) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const createWSClient = () => {
|
const createWSClient = () => {
|
||||||
|
const store = getDefaultStore();
|
||||||
let ws = new WebSocket(connectionString);
|
let ws = new WebSocket(connectionString);
|
||||||
let reconnectAttempts = 0;
|
let reconnectAttempts = 0;
|
||||||
const maxReconnectAttempts = 5;
|
const maxReconnectAttempts = 5;
|
||||||
|
let heartbeatInterval: number | null = null;
|
||||||
|
let heartbeatTimeoutId: number | null = null;
|
||||||
|
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
|
||||||
|
const HEARTBEAT_TIMEOUT = 5000; // 5 seconds to wait for pong
|
||||||
|
|
||||||
|
const startHeartbeat = () => {
|
||||||
|
stopHeartbeat();
|
||||||
|
heartbeatInterval = window.setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: "ping", id: crypto.randomUUID() }));
|
||||||
|
|
||||||
|
heartbeatTimeoutId = window.setTimeout(() => {
|
||||||
|
console.warn("Heartbeat timeout - closing connection");
|
||||||
|
ws.close();
|
||||||
|
}, HEARTBEAT_TIMEOUT);
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_INTERVAL);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopHeartbeat = () => {
|
||||||
|
if (heartbeatInterval) {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
heartbeatInterval = null;
|
||||||
|
}
|
||||||
|
if (heartbeatTimeoutId) {
|
||||||
|
clearTimeout(heartbeatTimeoutId);
|
||||||
|
heartbeatTimeoutId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const tryReconnect = () => {
|
const tryReconnect = () => {
|
||||||
|
store.set(connectionStatusAtom, "reconnecting");
|
||||||
|
stopHeartbeat();
|
||||||
if (reconnectAttempts < maxReconnectAttempts) {
|
if (reconnectAttempts < maxReconnectAttempts) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
reconnectAttempts++;
|
reconnectAttempts++;
|
||||||
|
|
@ -33,14 +67,18 @@ const createWSClient = () => {
|
||||||
}, 1000 * reconnectAttempts);
|
}, 1000 * reconnectAttempts);
|
||||||
} else {
|
} else {
|
||||||
console.error("Max reconnect attempts reached");
|
console.error("Max reconnect attempts reached");
|
||||||
|
store.set(connectionStatusAtom, "disconnected");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
|
store.set(connectionStatusAtom, "connecting");
|
||||||
ws = new WebSocket(connectionString);
|
ws = new WebSocket(connectionString);
|
||||||
|
|
||||||
ws.onopen = async () => {
|
ws.onopen = async () => {
|
||||||
reconnectAttempts = 0;
|
reconnectAttempts = 0;
|
||||||
|
store.set(connectionStatusAtom, "connected");
|
||||||
|
startHeartbeat();
|
||||||
const token = localStorage.getItem("loginToken");
|
const token = localStorage.getItem("loginToken");
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -69,25 +107,36 @@ const createWSClient = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
addMessageListener((event: MessageEvent) => {
|
addMessageListener((event: MessageEvent) => {
|
||||||
const data = JSON.parse(event.data) as Events;
|
const data = JSON.parse(event.data);
|
||||||
if (data.type === "updateGame") {
|
|
||||||
|
// Handle pong response
|
||||||
|
if (data.type === "pong") {
|
||||||
|
if (heartbeatTimeoutId) {
|
||||||
|
clearTimeout(heartbeatTimeoutId);
|
||||||
|
heartbeatTimeoutId = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventData = data as Events;
|
||||||
|
if (eventData.type === "updateGame") {
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
["game.getGameState", data.game],
|
["game.getGameState", eventData.game],
|
||||||
data.gameState,
|
eventData.gameState,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (data.type === "loss") {
|
if (eventData.type === "loss") {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["scoreboard.getScoreBoard", 10],
|
queryKey: ["scoreboard.getScoreBoard", 10],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (data.type === "gemsRewarded") {
|
if (eventData.type === "gemsRewarded") {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["user.getOwnGems", null],
|
queryKey: ["user.getOwnGems", null],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.log("Received message", data);
|
console.log("Received message", eventData);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue