Private
Public Access
1
0
Files
MineSeeker/assets/js/mine-seeker/components/OnlinePlayersDialog.jsx

253 lines
8.3 KiB
React
Raw Normal View History

/**
* 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, useRef, 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 [challengingGameAssoc, setChallengingGameAssoc] = useState(null);
const [declinedMsg, setDeclinedMsg] = useState('');
const declinedTimerRef = useRef(null);
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]);
useEffect(() => {
const handler = () => {
setChallengingGameAssoc(null);
clearTimeout(declinedTimerRef.current);
setDeclinedMsg('Challenge was not accepted.');
declinedTimerRef.current = setTimeout(() => setDeclinedMsg(''), 3500);
};
window.addEventListener('challenge-declined', handler);
return () => {
window.removeEventListener('challenge-declined', handler);
clearTimeout(declinedTimerRef.current);
};
}, []);
const handleChallenge = player => {
if (challengingGameAssoc) return;
setChallengingGameAssoc(player.gameAssoc);
setDeclinedMsg('');
fetch('/api/game/challenge/' + player.gameAssoc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challengerGameAssoc: currentGameAssoc }),
}).catch(() => setChallengingGameAssoc(null));
};
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>
)}
{declinedMsg && (
<div className="opd-declined">
<i className="fa fa-times-circle" />
{' '}{declinedMsg}
</div>
)}
{!loading && shown.map(player => {
const isWaiting = challengingGameAssoc === player.gameAssoc;
return (
<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>
<button
className={`opd-join${isWaiting ? ' opd-join--waiting' : ''}`}
onClick={() => handleChallenge(player)}
disabled={!!challengingGameAssoc}
>
<i className={`fa ${isWaiting ? 'fa-spinner fa-spin' : 'fa-play'}`} />
{isWaiting ? 'Waiting...' : 'Join'}
</button>
</div>
);
})}
</div>
{!loading && hasMore && (
<p className="opd-note">
Showing 5 of {visible.length} waiting players
</p>
)}
</div>
</Dialog>
);
};
export default OnlinePlayersDialog;