new: usr: add timer for the acceptance of the challenge #4
This commit is contained in:
@@ -305,3 +305,43 @@
|
||||
padding-top: 14px;
|
||||
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 [challengingGameAssoc, setChallengingGameAssoc] = useState(null);
|
||||
const [declinedMsg, setDeclinedMsg] = useState('');
|
||||
const [waitingCountdown, setWaitingCountdown] = useState(0);
|
||||
const declinedTimerRef = useRef(null);
|
||||
|
||||
const addPlayer = useCallback(entry => {
|
||||
@@ -111,6 +112,7 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
|
||||
setChallengingGameAssoc(null);
|
||||
clearTimeout(declinedTimerRef.current);
|
||||
setDeclinedMsg('Challenge was not accepted.');
|
||||
setWaitingCountdown(0);
|
||||
declinedTimerRef.current = setTimeout(() => setDeclinedMsg(''), 3500);
|
||||
};
|
||||
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 => {
|
||||
if (challengingGameAssoc) return;
|
||||
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));
|
||||
}).catch(() => {
|
||||
setChallengingGameAssoc(null);
|
||||
setWaitingCountdown(0);
|
||||
});
|
||||
};
|
||||
|
||||
const visible = players
|
||||
@@ -147,7 +164,12 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
|
||||
}
|
||||
|
||||
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-header">
|
||||
<div className="opd-header-text">
|
||||
@@ -160,32 +182,44 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
|
||||
<div className="opd-header-actions">
|
||||
<button
|
||||
className={`opd-refresh${loading ? ' opd-refresh--spin' : ''}`}
|
||||
onClick={() => setRefreshKey(k => k + 1)}
|
||||
disabled={loading}
|
||||
onClick={() => { if (waitingCountdown === 0) setRefreshKey(k => k + 1); }}
|
||||
disabled={loading || waitingCountdown > 0}
|
||||
aria-label="Refresh"
|
||||
title="Refresh list"
|
||||
>
|
||||
<i className="fa fa-refresh" />
|
||||
</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" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="opd-search-wrap">
|
||||
<i className="fa fa-search opd-search-icon" />
|
||||
<input
|
||||
className="opd-search"
|
||||
placeholder="Search by username…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
{search && (
|
||||
<button className="opd-search-clear" onClick={() => setSearch('')}>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{0 < waitingCountdown ? (
|
||||
<div className="opd-waiting">
|
||||
<i className="fa fa-hourglass-start" />
|
||||
<p>Waiting {waitingCountdown} second{1 === waitingCountdown ? '' : 's'} for opponent's answer...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="opd-search-wrap">
|
||||
<i className="fa fa-search opd-search-icon" />
|
||||
<input
|
||||
className="opd-search"
|
||||
placeholder="Search by username…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
{search && (
|
||||
<button className="opd-search-clear" onClick={() => setSearch('')}>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="opd-list">
|
||||
{loading && (
|
||||
<div className="opd-empty">
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
export { GameBoard } from './GameBoard';
|
||||
export { default as OnlinePlayersDialog } from './OnlinePlayersDialog';
|
||||
export { default as WaitingOverlayContent } from './WaitingOverlayContent';
|
||||
export { default as ChallengeCountdown } from './ChallengeCountdown';
|
||||
export { default as GameTimer } from './GameTimer';
|
||||
export { default as GridControl } from './grid/GridControl';
|
||||
export { default as GridField } from './grid/GridField';
|
||||
|
||||
@@ -14,7 +14,8 @@ import { DESC } from '@mine-utils';
|
||||
import useStepTimer from './useStepTimer';
|
||||
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 {
|
||||
/** Async-safe refs */
|
||||
@@ -136,8 +137,10 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
||||
|
||||
const wChallenge = payload => {
|
||||
const { challengerName, challengerGameAssoc } = payload;
|
||||
let declineTimeout = null;
|
||||
|
||||
const handleAccept = () => {
|
||||
clearTimeout(declineTimeout);
|
||||
fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -148,6 +151,7 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
||||
};
|
||||
|
||||
const handleDecline = () => {
|
||||
clearTimeout(declineTimeout);
|
||||
fetch('/api/game/challenge/respond/' + challengerGameAssoc, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -162,12 +166,11 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
declineTimeout = setTimeout(handleDecline, 30000);
|
||||
|
||||
showOverlay(
|
||||
challengerName + ' wants to challenge you!',
|
||||
<div className="resign">
|
||||
<a onClick={handleAccept}>Accept</a>
|
||||
<a onClick={handleDecline}>Decline</a>
|
||||
</div>,
|
||||
<ChallengeCountdown onAccept={handleAccept} onDecline={handleDecline} />,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user