Private
Public Access
1
0

new: usr: add timer for the acceptance of the challenge #4

This commit is contained in:
2026-04-14 20:30:18 +02:00
parent 3525aaeeb7
commit b134358e9e
5 changed files with 143 additions and 24 deletions

View File

@@ -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); }
}

View 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;

View File

@@ -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">

View File

@@ -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';

View File

@@ -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} />,
);
};