Private
Public Access
1
0

chg: dev: massive refactor on fetches - create centralized dataProvider #7

This commit is contained in:
2026-04-19 20:56:51 +02:00
parent 5da8a04c18
commit d9059acb78
6 changed files with 202 additions and 98 deletions

View File

@@ -9,6 +9,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Dialog from '@mui/material/Dialog';
import { useLobbyDataProvider } from '@mine-hooks';
const DIALOG_SX = {
'& .MuiDialog-paper': {
@@ -39,7 +40,7 @@ const formatSince = isoStr => {
return `${diff} min ago`;
};
const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc, isEnvDev = false }) => {
const [players, setPlayers] = useState([]);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(false);
@@ -49,6 +50,7 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
const [declinedMsg, setDeclinedMsg] = useState('');
const [waitingCountdown, setWaitingCountdown] = useState(0);
const declinedTimerRef = useRef(null);
const { waitingPlayersQuery, challengeMutation } = useLobbyDataProvider();
const addPlayer = useCallback(entry => {
setPlayers(prev =>
@@ -66,20 +68,21 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
if (!open) return;
setLoading(true);
setSnapshotLoaded(false);
fetch('/api/game/waiting')
.then(r => r.json())
.then(data => {
waitingPlayersQuery.refetch().then(result => {
if (result.data) {
// Filter out current user's game from the snapshot
const filtered = data.filter(p => p.gameAssoc !== currentGameAssoc);
const filtered = result.data.filter(p => p.gameAssoc !== currentGameAssoc);
setPlayers(filtered);
setSnapshotLoaded(true);
setLoading(false);
})
.catch(() => {
setPlayers([]);
setSnapshotLoaded(true);
setLoading(false);
});
}
setSnapshotLoaded(true);
setLoading(false);
}).catch(() => {
setPlayers([]);
setSnapshotLoaded(true);
setLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, refreshKey, currentGameAssoc]);
useEffect(() => {
@@ -107,6 +110,13 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
return () => es.close();
}, [open, snapshotLoaded, addPlayer, removePlayer, currentGameAssoc]);
useEffect(() => {
if (challengeMutation.isError) {
setChallengingGameAssoc(null);
setWaitingCountdown(0);
}
}, [challengeMutation.isError]);
useEffect(() => {
const handler = () => {
setChallengingGameAssoc(null);
@@ -138,14 +148,10 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
setChallengingGameAssoc(player.gameAssoc);
setDeclinedMsg('');
setWaitingCountdown(30);
fetch('/api/game/challenge/' + player.gameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challengerGameAssoc: currentGameAssoc }),
}).catch(() => {
setChallengingGameAssoc(null);
setWaitingCountdown(0);
});
challengeMutation.mutate(
{ targetGameAssoc: player.gameAssoc, challengerGameAssoc: currentGameAssoc }
);
};
const visible = players
@@ -156,17 +162,18 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
const hasMore = 5 < visible.length;
// Debug: log if currentGameAssoc is undefined or if current user appears
if ('development' === process.env.NODE_ENV && 0 < players.length) {
if (isEnvDev && 0 < players.length) {
const userInList = players.find(p => p.gameAssoc === currentGameAssoc);
if (userInList) {
console.warn('[OnlinePlayersDialog] Current user appears in players list:', { currentGameAssoc, userInList });
}
}
return (
<Dialog
open={open}
onClose={0 < waitingCountdown ? undefined : onClose}
<Dialog
open={open}
onClose={0 < waitingCountdown ? undefined : onClose}
disableEscapeKeyDown={0 < waitingCountdown}
sx={DIALOG_SX}
>
@@ -189,9 +196,9 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
>
<i className="fa fa-refresh" />
</button>
<button
className="opd-close"
onClick={() => { if (0 === waitingCountdown) onClose(); }}
<button
className="opd-close"
onClick={() => { if (0 === waitingCountdown) onClose(); }}
disabled={0 < waitingCountdown}
aria-label="Close"
>

View File

@@ -22,7 +22,7 @@ const GridControl = ({ gameAssoc, onClick, resign }) => {
} = useGame();
const [copied, setCopied] = useState(false);
const shareUrl = gameAssoc ? `${window.location.origin}/battle/${gameAssoc}` : null;
const shareUrl = gameAssoc ? `${window.location.origin}/play/${gameAssoc}` : null;
const isAuthenticated = '1' === document.getElementById('mine-wrapper')?.dataset.isAuthenticated;
const handleShare = () => {

View File

@@ -11,4 +11,4 @@ export { default as useGameRefs } from './useGameRefs';
export { default as useGameState } from './useGameState';
export { default as useServerCommunication } from './useServerCommunication';
export { default as useStepTimer } from './useStepTimer';
export { default as useGameDataProvider, useLobbyDataProvider } from './useGameDataProvider';

View File

@@ -0,0 +1,114 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import { useQuery, useMutation } from '@tanstack/react-query';
/**
* Game Data Provider Hook
* Centralized API communication layer for game-related queries and mutations
*/
const useGameDataProvider = gameAssoc => {
// Queries
const connectQuery = useQuery({
queryKey: ['game-connect', gameAssoc],
queryFn: () => fetch(`/api/game/connect/${gameAssoc}`)
.then(r => r.text())
.then(b64 => JSON.parse(window.atob(b64))),
enabled: false,
retry: false,
});
// Mutations
const startMutation = useMutation({
mutationFn: () => fetch('/api/game/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gameAssoc }),
}),
});
const joinMutation = useMutation({
mutationFn: () => fetch(`/api/game/join/${gameAssoc}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
});
const stepMutation = useMutation({
mutationFn: dataPack => fetch(`/api/game/step/${gameAssoc}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dataPack),
}).then(r => r.json()),
});
const heartbeatMutation = useMutation({
mutationFn: color => fetch(`/api/game/heartbeat/${gameAssoc}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ color }),
}).then(r => r.json()),
});
const challengeRespondMutation = useMutation({
mutationFn: ({ challengerGameAssoc, accepted, targetGameAssoc }) => fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted, targetGameAssoc }),
}).then(r => r.json()),
});
const leaveMutation = useMutation({
mutationFn: () => fetch(`/api/game/leave/${gameAssoc}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}).then(r => r.json()),
});
return {
// Queries
connectQuery,
// Mutations
startMutation,
joinMutation,
stepMutation,
heartbeatMutation,
challengeRespondMutation,
leaveMutation,
};
};
/**
* Lobby Data Provider Hook
* Centralized API communication layer for lobby-related queries and mutations
*/
export const useLobbyDataProvider = () => {
const waitingPlayersQuery = useQuery({
queryKey: ['game-waiting'],
queryFn: () => fetch('/api/game/waiting')
.then(r => r.json()),
});
const challengeMutation = useMutation({
mutationFn: ({ targetGameAssoc, challengerGameAssoc }) => fetch(`/api/game/challenge/${targetGameAssoc}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challengerGameAssoc }),
}).then(r => r.json()),
});
return {
// Queries
waitingPlayersQuery,
// Mutations
challengeMutation,
};
};
export default useGameDataProvider;

View File

@@ -8,10 +8,10 @@
*/
import React, { useEffect, useRef } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useGame } from '@mine-contexts';
import { DESC, IMAGES } from '@mine-utils';
import useStepTimer from './useStepTimer';
import useGameDataProvider from './useGameDataProvider';
import { ChallengeCountdown, WaitingOverlayContent } from '@mine-components';
const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev) => {
@@ -28,6 +28,17 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
cells,
} = useGame();
/** Get all API queries and mutations from data provider */
const {
connectQuery,
startMutation,
joinMutation,
stepMutation,
heartbeatMutation,
challengeRespondMutation,
leaveMutation,
} = useGameDataProvider(gameAssoc);
const eventSourceRef = useRef(null);
const rpcUsersRef = useRef(null);
const stepCacheRef = useRef([]);
@@ -42,40 +53,6 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
const HEARTBEAT_INTERVAL_MS = 1500;
/** REST mutations / queries */
const connectQuery = useQuery({
queryKey: ['game-connect', gameAssoc],
queryFn: () => fetch('/api/game/connect/' + gameAssoc)
.then(r => r.text())
.then(b64 => JSON.parse(window.atob(b64))),
enabled: false,
retry: false,
});
const startMutation = useMutation({
mutationFn: () => fetch('/api/game/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gameAssoc }),
}),
});
const joinMutation = useMutation({
mutationFn: () => fetch('/api/game/join/' + gameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}).catch(e => isEnvDev && console.error('Join error', e)),
});
const stepMutation = useMutation({
mutationFn: dataPack => fetch('/api/game/step/' + gameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dataPack),
}).then(r => r.json()),
});
/** Game-start helpers (triggered by server events) */
const wInit = (revealedCells = [], lastStep = {}, gameState = {}, isGameFinished = false) => {
@@ -176,7 +153,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
overlayTitle = 'Choose an opponent!';
overlaySubtitle = gameAssoc ? (
<WaitingOverlayContent
shareUrl={`${window.location.origin}/battle/${gameAssoc}`}
shareUrl={`${window.location.origin}/play/${gameAssoc}`}
currentGameAssoc={gameAssoc}
/>
) : '';
@@ -188,7 +165,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
Promise.resolve().then(() => setGridReady(true));
};
const makeGameStart = (payload, lastStep = {}) => {
const makeGameStart = payload => {
/** Don't start a finished game */
if (isGameFinishedRef.current) {
return;
@@ -242,11 +219,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
const publishHeartbeat = () => {
const me = webPlayerRef.current;
if (!me || endRef.current) return;
fetch('/api/game/heartbeat/' + gameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ color: me }),
}).catch(e => isEnvDev && console.warn('Heartbeat publish failed', e));
heartbeatMutation.mutate(me);
};
const startHeartbeat = () => {
@@ -264,7 +237,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
/** Mercure / SSE message handlers */
const wSubscribe = (payload, rpcUsers = null, lastStep = null) => {
const wSubscribe = (payload, rpcUsers = null) => {
isEnvDev && console.info((payload.user ?? 'user') + ' subscribed');
const firstUser = !rpcUsers;
@@ -279,7 +252,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
&& (!connectionLostRef.current
|| (connectionLostRef.current && false === activePlayerRef.current && !endRef.current))
) {
makeGameStart(payload, lastStep);
makeGameStart(payload);
}
};
@@ -315,31 +288,31 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
const handleAccept = () => {
clearTimeout(declineTimeout);
fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted: true, targetGameAssoc: gameAssoc }),
}).then(() => {
showOverlay('Challenge accepted!', 'Waiting for the challenger to join...');
}).catch(() => {
});
challengeRespondMutation.mutate(
{ challengerGameAssoc, accepted: true, targetGameAssoc: gameAssoc },
{
onSuccess: () => {
showOverlay('Challenge accepted!', 'Waiting for the challenger to join...');
},
}
);
};
const handleDecline = () => {
clearTimeout(declineTimeout);
fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted: false, targetGameAssoc: gameAssoc }),
}).then(() => {
showOverlay('We are waiting for your opponent...', gameAssoc ? (
<WaitingOverlayContent
shareUrl={window.location.origin + '/play/' + gameAssoc}
currentGameAssoc={gameAssoc}
/>
) : '');
}).catch(() => {
});
challengeRespondMutation.mutate(
{ challengerGameAssoc, accepted: false, targetGameAssoc: gameAssoc },
{
onSuccess: () => {
showOverlay('We are waiting for your opponent...', gameAssoc ? (
<WaitingOverlayContent
shareUrl={`${window.location.origin}/play/${gameAssoc}`}
currentGameAssoc={gameAssoc}
/>
) : '');
},
}
);
};
declineTimeout = setTimeout(handleDecline, 30000);
@@ -404,7 +377,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
if (undefined !== payload.data) {
wTopic(payload);
} else if (undefined === payload.msg) {
wSubscribe(payload, rpcUsersRef.current, lastStepRef.current);
wSubscribe(payload, rpcUsersRef.current);
} else {
wUnsubscribe(payload);
}
@@ -423,7 +396,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
const subscriberJwt = wrapper.dataset.mercureSubscriberJwt;
const url = new URL(hubUrl, window.location.origin);
url.searchParams.append('topic', 'mineseeker/channel/' + gameAssoc);
url.searchParams.append('topic', `mineseeker/channel/${gameAssoc}`);
if (subscriberJwt) url.searchParams.append('authorization', subscriberJwt);
if (eventSourceRef.current) eventSourceRef.current.close();
@@ -452,6 +425,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
openEventSource();
return;
}
try {
if (gameInherited) {
const serverData = await connectQuery.refetch().then(r => {
@@ -497,7 +471,9 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
}
})();
window.addEventListener('pagehide', () => navigator.sendBeacon('/api/game/leave/' + gameAssoc));
window.addEventListener('pagehide', () => {
leaveMutation.mutate();
});
return () => {
stopHeartbeat();
@@ -525,9 +501,11 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
try {
const result = await stepMutation.mutateAsync(dataPack);
applyStep(result);
if (result.uuid && !endRef.current) {
setGameUuid(result.uuid);
}
makeGameEndIfItEnds(result.bluePoints, result.redPoints, false, result.leftMines);
} catch (e) {
isEnvDev && console.error('Step error', e);
@@ -537,6 +515,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
const clickResign = () => {
const color = activePlayerRef.current ? 'blue' : 'red';
const stepElapsed = getStepElapsed(activePlayerRef.current, isGameRunningRef.current);
stepMutation.mutate(
{ resign: color, stepElapsed },
{
@@ -551,7 +530,9 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
const resign = () => {
const activeColor = activePlayerRef.current ? 'blue' : 'red';
if (webPlayerRef.current !== activeColor) return;
showOverlay('Are u sure u want to resign?!', (
<div className="resign">
<a onClick={clickResign}>Yes</a>