new: usr: add timer for the acceptance of the challenge #4
This commit is contained in:
@@ -305,3 +305,43 @@
|
|||||||
padding-top: 14px;
|
padding-top: 14px;
|
||||||
border-top: 1px solid rgba(35, 111, 135, 0.14);
|
border-top: 1px solid rgba(35, 111, 135, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.opd-header-actions {
|
||||||
|
.opd-refresh[disabled] {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opd-close[disabled] {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.opd-waiting {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: rgba(35, 111, 135, 0.07);
|
||||||
|
border: 1px solid rgba(35, 111, 135, 0.28);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #95cff5;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 16px;
|
||||||
|
animation: opd-hourglass 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font: 600 14px 'Rajdhani', sans-serif;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes opd-hourglass {
|
||||||
|
0%, 100% { transform: rotate(0deg); }
|
||||||
|
50% { transform: rotate(180deg); }
|
||||||
|
}
|
||||||
|
|||||||
41
assets/js/mine-seeker/components/ChallengeCountdown.jsx
Normal file
41
assets/js/mine-seeker/components/ChallengeCountdown.jsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* 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 { Fragment, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const ChallengeCountdown = ({ onAccept, onDecline, seconds = 30 }) => {
|
||||||
|
const [countdown, setCountdown] = useState(seconds);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCountdown(prev => {
|
||||||
|
if (1 >= prev) {
|
||||||
|
clearInterval(interval);
|
||||||
|
onDecline();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [onDecline]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<p style={{ textAlign: 'center', marginBottom: 20, color: '#95cff5' }}>
|
||||||
|
You have {countdown} second{1 === countdown ? '' : 's'} to answer to the challenge!
|
||||||
|
</p>
|
||||||
|
<div className="resign">
|
||||||
|
<a onClick={onAccept}>Accept</a>
|
||||||
|
<a onClick={onDecline}>Decline</a>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChallengeCountdown;
|
||||||
@@ -47,6 +47,7 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
|
|||||||
const [snapshotLoaded, setSnapshotLoaded] = useState(false);
|
const [snapshotLoaded, setSnapshotLoaded] = useState(false);
|
||||||
const [challengingGameAssoc, setChallengingGameAssoc] = useState(null);
|
const [challengingGameAssoc, setChallengingGameAssoc] = useState(null);
|
||||||
const [declinedMsg, setDeclinedMsg] = useState('');
|
const [declinedMsg, setDeclinedMsg] = useState('');
|
||||||
|
const [waitingCountdown, setWaitingCountdown] = useState(0);
|
||||||
const declinedTimerRef = useRef(null);
|
const declinedTimerRef = useRef(null);
|
||||||
|
|
||||||
const addPlayer = useCallback(entry => {
|
const addPlayer = useCallback(entry => {
|
||||||
@@ -111,6 +112,7 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
|
|||||||
setChallengingGameAssoc(null);
|
setChallengingGameAssoc(null);
|
||||||
clearTimeout(declinedTimerRef.current);
|
clearTimeout(declinedTimerRef.current);
|
||||||
setDeclinedMsg('Challenge was not accepted.');
|
setDeclinedMsg('Challenge was not accepted.');
|
||||||
|
setWaitingCountdown(0);
|
||||||
declinedTimerRef.current = setTimeout(() => setDeclinedMsg(''), 3500);
|
declinedTimerRef.current = setTimeout(() => setDeclinedMsg(''), 3500);
|
||||||
};
|
};
|
||||||
window.addEventListener('challenge-declined', handler);
|
window.addEventListener('challenge-declined', handler);
|
||||||
@@ -120,15 +122,30 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!waitingCountdown) return;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setWaitingCountdown(prev => {
|
||||||
|
if (1 >= prev) return 0;
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [waitingCountdown]);
|
||||||
|
|
||||||
const handleChallenge = player => {
|
const handleChallenge = player => {
|
||||||
if (challengingGameAssoc) return;
|
if (challengingGameAssoc) return;
|
||||||
setChallengingGameAssoc(player.gameAssoc);
|
setChallengingGameAssoc(player.gameAssoc);
|
||||||
setDeclinedMsg('');
|
setDeclinedMsg('');
|
||||||
|
setWaitingCountdown(30);
|
||||||
fetch('/api/game/challenge/' + player.gameAssoc, {
|
fetch('/api/game/challenge/' + player.gameAssoc, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ challengerGameAssoc: currentGameAssoc }),
|
body: JSON.stringify({ challengerGameAssoc: currentGameAssoc }),
|
||||||
}).catch(() => setChallengingGameAssoc(null));
|
}).catch(() => {
|
||||||
|
setChallengingGameAssoc(null);
|
||||||
|
setWaitingCountdown(0);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const visible = players
|
const visible = players
|
||||||
@@ -147,7 +164,12 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} sx={DIALOG_SX}>
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={waitingCountdown > 0 ? undefined : onClose}
|
||||||
|
disableEscapeKeyDown={waitingCountdown > 0}
|
||||||
|
sx={DIALOG_SX}
|
||||||
|
>
|
||||||
<div className="opd">
|
<div className="opd">
|
||||||
<div className="opd-header">
|
<div className="opd-header">
|
||||||
<div className="opd-header-text">
|
<div className="opd-header-text">
|
||||||
@@ -160,32 +182,44 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
|
|||||||
<div className="opd-header-actions">
|
<div className="opd-header-actions">
|
||||||
<button
|
<button
|
||||||
className={`opd-refresh${loading ? ' opd-refresh--spin' : ''}`}
|
className={`opd-refresh${loading ? ' opd-refresh--spin' : ''}`}
|
||||||
onClick={() => setRefreshKey(k => k + 1)}
|
onClick={() => { if (waitingCountdown === 0) setRefreshKey(k => k + 1); }}
|
||||||
disabled={loading}
|
disabled={loading || waitingCountdown > 0}
|
||||||
aria-label="Refresh"
|
aria-label="Refresh"
|
||||||
title="Refresh list"
|
title="Refresh list"
|
||||||
>
|
>
|
||||||
<i className="fa fa-refresh" />
|
<i className="fa fa-refresh" />
|
||||||
</button>
|
</button>
|
||||||
<button className="opd-close" onClick={onClose} aria-label="Close">
|
<button
|
||||||
|
className="opd-close"
|
||||||
|
onClick={() => { if (waitingCountdown === 0) onClose(); }}
|
||||||
|
disabled={waitingCountdown > 0}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
<i className="fa fa-times" />
|
<i className="fa fa-times" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="opd-search-wrap">
|
{0 < waitingCountdown ? (
|
||||||
<i className="fa fa-search opd-search-icon" />
|
<div className="opd-waiting">
|
||||||
<input
|
<i className="fa fa-hourglass-start" />
|
||||||
className="opd-search"
|
<p>Waiting {waitingCountdown} second{1 === waitingCountdown ? '' : 's'} for opponent's answer...</p>
|
||||||
placeholder="Search by username…"
|
</div>
|
||||||
value={search}
|
) : (
|
||||||
onChange={e => setSearch(e.target.value)}
|
<div className="opd-search-wrap">
|
||||||
/>
|
<i className="fa fa-search opd-search-icon" />
|
||||||
{search && (
|
<input
|
||||||
<button className="opd-search-clear" onClick={() => setSearch('')}>
|
className="opd-search"
|
||||||
<i className="fa fa-times" />
|
placeholder="Search by username…"
|
||||||
</button>
|
value={search}
|
||||||
)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
</div>
|
/>
|
||||||
|
{search && (
|
||||||
|
<button className="opd-search-clear" onClick={() => setSearch('')}>
|
||||||
|
<i className="fa fa-times" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="opd-list">
|
<div className="opd-list">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="opd-empty">
|
<div className="opd-empty">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
export { GameBoard } from './GameBoard';
|
export { GameBoard } from './GameBoard';
|
||||||
export { default as OnlinePlayersDialog } from './OnlinePlayersDialog';
|
export { default as OnlinePlayersDialog } from './OnlinePlayersDialog';
|
||||||
export { default as WaitingOverlayContent } from './WaitingOverlayContent';
|
export { default as WaitingOverlayContent } from './WaitingOverlayContent';
|
||||||
|
export { default as ChallengeCountdown } from './ChallengeCountdown';
|
||||||
export { default as GameTimer } from './GameTimer';
|
export { default as GameTimer } from './GameTimer';
|
||||||
export { default as GridControl } from './grid/GridControl';
|
export { default as GridControl } from './grid/GridControl';
|
||||||
export { default as GridField } from './grid/GridField';
|
export { default as GridField } from './grid/GridField';
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import { DESC } from '@mine-utils';
|
|||||||
import useStepTimer from './useStepTimer';
|
import useStepTimer from './useStepTimer';
|
||||||
import { WaitingOverlayContent } from '@mine-components';
|
import { WaitingOverlayContent } from '@mine-components';
|
||||||
|
|
||||||
/** Handles all server communication: SSE (Mercure), REST calls, and the initialization lifecycle. */
|
import { ChallengeCountdown } from '@mine-components';
|
||||||
|
|
||||||
const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
||||||
const {
|
const {
|
||||||
/** Async-safe refs */
|
/** Async-safe refs */
|
||||||
@@ -136,8 +137,10 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
|
|
||||||
const wChallenge = payload => {
|
const wChallenge = payload => {
|
||||||
const { challengerName, challengerGameAssoc } = payload;
|
const { challengerName, challengerGameAssoc } = payload;
|
||||||
|
let declineTimeout = null;
|
||||||
|
|
||||||
const handleAccept = () => {
|
const handleAccept = () => {
|
||||||
|
clearTimeout(declineTimeout);
|
||||||
fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
|
fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -148,6 +151,7 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDecline = () => {
|
const handleDecline = () => {
|
||||||
|
clearTimeout(declineTimeout);
|
||||||
fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
|
fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -162,12 +166,11 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
declineTimeout = setTimeout(handleDecline, 30000);
|
||||||
|
|
||||||
showOverlay(
|
showOverlay(
|
||||||
challengerName + ' wants to challenge you!',
|
challengerName + ' wants to challenge you!',
|
||||||
<div className="resign">
|
<ChallengeCountdown onAccept={handleAccept} onDecline={handleDecline} />,
|
||||||
<a onClick={handleAccept}>Accept</a>
|
|
||||||
<a onClick={handleDecline}>Decline</a>
|
|
||||||
</div>,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user