chg: usr: re-implement the waiting for opponent dialog - refactor its gfx - & add online user selection dialog #4
This commit is contained in:
210
assets/js/mine-seeker/components/OnlinePlayersDialog.jsx
Normal file
210
assets/js/mine-seeker/components/OnlinePlayersDialog.jsx
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* 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 React, { useCallback, useEffect, useState } from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
|
||||
const DIALOG_SX = {
|
||||
'& .MuiDialog-paper': {
|
||||
background: '#07090d',
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(35, 111, 135, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(35, 111, 135, 0.08) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '46px 46px',
|
||||
border: '1px solid rgba(35, 111, 135, 0.4)',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 0 80px rgba(35, 111, 135, 0.15), 0 32px 80px rgba(0, 0, 0, 0.9)',
|
||||
width: '500px',
|
||||
maxWidth: '94vw',
|
||||
overflow: 'hidden',
|
||||
color: '#fff',
|
||||
},
|
||||
'& .MuiBackdrop-root': {
|
||||
background: 'rgba(2, 4, 8, 0.88)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
},
|
||||
};
|
||||
|
||||
const formatSince = isoStr => {
|
||||
const diff = Math.floor((Date.now() - new Date(isoStr)) / 60000);
|
||||
if (1 > diff) return 'just now';
|
||||
if (1 === diff) return '1 min ago';
|
||||
return `${diff} min ago`;
|
||||
};
|
||||
|
||||
const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
|
||||
const [players, setPlayers] = useState([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [snapshotLoaded, setSnapshotLoaded] = useState(false);
|
||||
|
||||
const addPlayer = useCallback(entry => {
|
||||
setPlayers(prev =>
|
||||
prev.some(p => p.gameAssoc === entry.gameAssoc)
|
||||
? prev
|
||||
: [...prev, entry],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const removePlayer = useCallback(gameAssoc => {
|
||||
setPlayers(prev => prev.filter(p => p.gameAssoc !== gameAssoc));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLoading(true);
|
||||
setSnapshotLoaded(false);
|
||||
fetch('/api/game/waiting')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
// Filter out current user's game from the snapshot
|
||||
const filtered = data.filter(p => p.gameAssoc !== currentGameAssoc);
|
||||
setPlayers(filtered);
|
||||
setSnapshotLoaded(true);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setPlayers([]);
|
||||
setSnapshotLoaded(true);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [open, refreshKey, currentGameAssoc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !snapshotLoaded) return;
|
||||
setSearch('');
|
||||
|
||||
const wrapper = document.getElementById('mine-wrapper');
|
||||
const hubUrl = wrapper?.dataset?.mercureHubUrl;
|
||||
const jwt = wrapper?.dataset?.mercureSubscriberJwt;
|
||||
if (!hubUrl) return;
|
||||
|
||||
const url = new URL(hubUrl, window.location.origin);
|
||||
url.searchParams.append('topic', 'mineseeker/lobby');
|
||||
if (jwt) url.searchParams.append('authorization', jwt);
|
||||
|
||||
const es = new EventSource(url.toString());
|
||||
es.onmessage = e => {
|
||||
const { action, gameAssoc, name, since } = JSON.parse(e.data);
|
||||
// Don't add the current user's game to the list
|
||||
if (gameAssoc === currentGameAssoc) return;
|
||||
if ('join' === action) addPlayer({ gameAssoc, name, since });
|
||||
if ('leave' === action) removePlayer(gameAssoc);
|
||||
};
|
||||
|
||||
return () => es.close();
|
||||
}, [open, snapshotLoaded, addPlayer, removePlayer, currentGameAssoc]);
|
||||
|
||||
const visible = players
|
||||
.filter(p => p.gameAssoc !== currentGameAssoc)
|
||||
.filter(p => p.name.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
const shown = visible.slice(0, 5);
|
||||
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) {
|
||||
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={onClose} sx={DIALOG_SX}>
|
||||
<div className="opd">
|
||||
<div className="opd-header">
|
||||
<div className="opd-header-text">
|
||||
<span className="opd-label">Multiplayer</span>
|
||||
<h2 className="opd-title">
|
||||
<i className="fa fa-users" />
|
||||
Online Players
|
||||
</h2>
|
||||
</div>
|
||||
<div className="opd-header-actions">
|
||||
<button
|
||||
className={`opd-refresh${loading ? ' opd-refresh--spin' : ''}`}
|
||||
onClick={() => setRefreshKey(k => k + 1)}
|
||||
disabled={loading}
|
||||
aria-label="Refresh"
|
||||
title="Refresh list"
|
||||
>
|
||||
<i className="fa fa-refresh" />
|
||||
</button>
|
||||
<button className="opd-close" onClick={onClose} 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>
|
||||
<div className="opd-list">
|
||||
{loading && (
|
||||
<div className="opd-empty">
|
||||
<i className="fa fa-spinner fa-spin" />
|
||||
<p>Loading players…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && 0 === shown.length && (
|
||||
<div className="opd-empty">
|
||||
<i className="fa fa-hourglass-half" />
|
||||
<p>
|
||||
{search
|
||||
? 'No players match your search.'
|
||||
: 'No other players are waiting right now.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && shown.map(player => (
|
||||
<div key={player.gameAssoc} className="opd-row">
|
||||
<div className="opd-avatar">
|
||||
{player.name.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="opd-info">
|
||||
<span className="opd-name">{player.name}</span>
|
||||
<span className="opd-since">
|
||||
<i className="fa fa-clock-o" />
|
||||
{' '}Waiting {formatSince(player.since)}
|
||||
</span>
|
||||
</div>
|
||||
<a className="opd-join" href={`/play/${player.gameAssoc}`}>
|
||||
<i className="fa fa-play" />
|
||||
Join
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!loading && hasMore && (
|
||||
<p className="opd-note">
|
||||
Showing 5 of {visible.length} waiting players
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnlinePlayersDialog;
|
||||
85
assets/js/mine-seeker/components/WaitingOverlayContent.jsx
Normal file
85
assets/js/mine-seeker/components/WaitingOverlayContent.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 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, useState } from 'react';
|
||||
import { OnlinePlayersDialog } from '@mine-components';
|
||||
|
||||
const WaitingOverlayContent = ({ shareUrl, currentGameAssoc }) => {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="waiting-options">
|
||||
<div className="waiting-option">
|
||||
<div className="waiting-option-header">
|
||||
<i className="fa fa-link" />
|
||||
<span>Invite a Friend</span>
|
||||
</div>
|
||||
<p className="waiting-option-desc">Share this link with your opponent</p>
|
||||
<ShareLinkBox
|
||||
url={shareUrl}
|
||||
/>
|
||||
</div>
|
||||
<div className="waiting-divider">
|
||||
<span>OR</span>
|
||||
</div>
|
||||
<div className="waiting-option">
|
||||
<div className="waiting-option-header">
|
||||
<i className="fa fa-users" />
|
||||
<span>Challenge a Player</span>
|
||||
</div>
|
||||
<p className="waiting-option-desc">Browse online players and challenge them</p>
|
||||
<button
|
||||
className="browse-players-btn"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
<i className="fa fa-search" />
|
||||
Browse Players
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OnlinePlayersDialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
currentGameAssoc={currentGameAssoc}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const ShareLinkBox = ({ url }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2500);
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="share-invite">
|
||||
<div className="share-url-box" onClick={e => e.currentTarget.querySelector('input').select()}>
|
||||
<i className="fa fa-link share-url-icon" />
|
||||
<input
|
||||
className="share-url-input"
|
||||
readOnly
|
||||
value={url}
|
||||
onClick={e => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<button className={`share-copy-btn${copied ? ' copied' : ''}`} onClick={handleCopy}>
|
||||
<i className={`fa ${copied ? 'fa-check' : 'fa-clipboard'}`} />
|
||||
{copied ? 'Copied!' : 'Copy Link'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WaitingOverlayContent;
|
||||
@@ -8,6 +8,8 @@
|
||||
*/
|
||||
|
||||
export { GameBoard } from './GameBoard';
|
||||
export { default as OnlinePlayersDialog } from './OnlinePlayersDialog';
|
||||
export { default as WaitingOverlayContent } from './WaitingOverlayContent';
|
||||
export { default as GameTimer } from './GameTimer';
|
||||
export { default as GridControl } from './grid/GridControl';
|
||||
export { default as GridField } from './grid/GridField';
|
||||
|
||||
@@ -7,41 +7,12 @@
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useGame } from '@mine-contexts';
|
||||
import { DESC } from '@mine-utils';
|
||||
import useStepTimer from './useStepTimer';
|
||||
|
||||
const ShareLinkBox = ({ url }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2500);
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="share-invite">
|
||||
<p className="share-invite-label">Share this link with your opponent</p>
|
||||
<div className="share-url-box" onClick={e => e.currentTarget.querySelector('input').select()}>
|
||||
<i className="fa fa-link share-url-icon" />
|
||||
<input
|
||||
className="share-url-input"
|
||||
readOnly
|
||||
value={url}
|
||||
onClick={e => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
<button className={`share-copy-btn${copied ? ' copied' : ''}`} onClick={handleCopy}>
|
||||
<i className={`fa ${copied ? 'fa-check' : 'fa-clipboard'}`} />
|
||||
{copied ? 'Copied!' : 'Copy link'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { WaitingOverlayContent } from '@mine-components';
|
||||
|
||||
/** Handles all server communication: SSE (Mercure), REST calls, and the initialization lifecycle. */
|
||||
const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
||||
@@ -106,7 +77,10 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
||||
const wInit = (revealedCells = []) => {
|
||||
setGridReady(true);
|
||||
showOverlay('We are waiting for your opponent...', gameAssoc ? (
|
||||
<ShareLinkBox url={`${window.location.href}/${gameAssoc}`} />
|
||||
<WaitingOverlayContent
|
||||
shareUrl={`${window.location.href}/${gameAssoc}`}
|
||||
currentGameAssoc={gameAssoc}
|
||||
/>
|
||||
) : '');
|
||||
setTimeout(() => revealedCells.forEach(cell => applyRevealedCell(cell, cell.player)), 0);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user