import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../api/axios';
import GamePoolModal from '../components/GamePoolModal';
import RoomCodeModal from '../components/RoomCodeModal';
import { formatLocalTime } from '../utils/dateUtils';
import PopularityBadge from '../components/PopularityBadge';
function Picker() {
const { isAuthenticated, loading: authLoading, token } = useAuth();
const navigate = useNavigate();
const [activeSession, setActiveSession] = useState(null);
const [allGames, setAllGames] = useState([]);
const [selectedGame, setSelectedGame] = useState(null);
const [playingGame, setPlayingGame] = useState(null);
const [hasPlayedGames, setHasPlayedGames] = useState(false);
const [leadingGame, setLeadingGame] = useState(null);
const [pollActive, setPollActive] = useState(false);
const [pollResult, setPollResult] = useState(null);
const [pollElapsed, setPollElapsed] = useState(0);
const pollTimerRef = useRef(null);
const pollStartedAtRef = useRef(null);
const [pollEndingAt, setPollEndingAt] = useState(null);
const [pollCountdown, setPollCountdown] = useState(null);
const pollCountdownRef = useRef(null);
const [showEndPollOptions, setShowEndPollOptions] = useState(false);
const [customDelay, setCustomDelay] = useState('');
const [loading, setLoading] = useState(true);
const [picking, setPicking] = useState(false);
const [error, setError] = useState('');
const [showPopularity, setShowPopularity] = useState(true);
const [sessionEnded, setSessionEnded] = useState(false);
// Filters
const [playerCount, setPlayerCount] = useState('');
const [drawingFilter, setDrawingFilter] = useState('both');
const [lengthFilter, setLengthFilter] = useState('');
const [familyFriendlyFilter, setFamilyFriendlyFilter] = useState('');
// Manual game selection
const [showManualSelect, setShowManualSelect] = useState(false);
const [manualGameId, setManualGameId] = useState('');
const [manualSearchQuery, setManualSearchQuery] = useState('');
const [showManualDropdown, setShowManualDropdown] = useState(false);
const [filteredManualGames, setFilteredManualGames] = useState([]);
// Game pool viewer
const [showGamePool, setShowGamePool] = useState(false);
const [eligibleGames, setEligibleGames] = useState([]);
// Trigger to refresh session games list
const [gamesUpdateTrigger, setGamesUpdateTrigger] = useState(0);
// Mobile filters toggle
const [showFilters, setShowFilters] = useState(false);
// Exclude previously played games
const [excludePlayedGames, setExcludePlayedGames] = useState(false);
// Room code modal
const [showRoomCodeModal, setShowRoomCodeModal] = useState(false);
const [pendingGameAction, setPendingGameAction] = useState(null);
const checkActiveSession = useCallback(async () => {
try {
const sessionResponse = await api.get('/sessions/active');
const session = sessionResponse.data?.session !== undefined
? sessionResponse.data.session
: sessionResponse.data;
// Check if session status changed
setActiveSession(prevSession => {
// If we had a session but now don't, mark it as ended
if (prevSession && (!session || !session.id)) {
setSessionEnded(true);
return null;
} else if (session && session.id) {
setSessionEnded(false);
return session;
}
return prevSession;
});
} catch (err) {
console.error('Failed to check session status', err);
}
}, []);
const loadData = useCallback(async () => {
try {
// Load active session
const sessionResponse = await api.get('/sessions/active');
// Handle new format { session: null } or old format (direct session object)
let session = sessionResponse.data?.session !== undefined
? sessionResponse.data.session
: sessionResponse.data;
// Don't auto-create session - let user create it explicitly
setActiveSession(session);
// Load all enabled games for manual selection
const gamesResponse = await api.get('/games?enabled=true');
setAllGames(gamesResponse.data);
// Load currently playing game and restore poll state if session exists
if (session && session.id) {
// Restore poll state from persisted session data
if (session.poll_active) {
pollStartedAtRef.current = session.poll_started_at || null;
setPollActive(true);
if (session.poll_leading_game_id) {
setLeadingGame({
gameId: session.poll_leading_game_id,
label: session.poll_leading_label,
votes: session.poll_leading_votes,
});
}
if (session.poll_ending_at && new Date(session.poll_ending_at) > new Date()) {
setPollEndingAt(session.poll_ending_at);
}
}
// Restore pending game selection if another admin picked one
if (session.pending_game_id) {
const pendingGame = gamesResponse.data.find(g => g.id === session.pending_game_id);
if (pendingGame) {
setSelectedGame(pendingGame);
setGameSource(session.pending_game_source || 'dice');
}
}
try {
const sessionGamesResponse = await api.get(`/sessions/${session.id}/games`);
const playingGameEntry = sessionGamesResponse.data.find(g => g.status === 'playing');
if (playingGameEntry) {
setPlayingGame(playingGameEntry);
} else {
setPlayingGame(null);
}
setHasPlayedGames(sessionGamesResponse.data.some(g => g.status === 'played'));
} catch (err) {
console.error('Failed to load playing game', err);
}
}
} catch (err) {
setError('Failed to load session data');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
// Wait for auth to finish loading before checking authentication
if (authLoading) return;
if (!isAuthenticated) {
navigate('/login');
return;
}
loadData();
}, [isAuthenticated, authLoading, navigate, loadData]);
// Fallback poll for session status — WebSocket events handle most updates
useEffect(() => {
if (!isAuthenticated || authLoading) return;
const interval = setInterval(() => {
checkActiveSession();
}, 60000);
return () => clearInterval(interval);
}, [isAuthenticated, authLoading, checkActiveSession]);
// Close manual game dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (showManualDropdown && !event.target.closest('.manual-search-container')) {
setShowManualDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showManualDropdown]);
useEffect(() => {
if (pollActive) {
const start = pollStartedAtRef.current
? new Date(pollStartedAtRef.current).getTime()
: Date.now();
pollTimerRef.current = setInterval(() => {
setPollElapsed(Date.now() - start);
}, 10);
} else {
clearInterval(pollTimerRef.current);
setPollElapsed(0);
pollStartedAtRef.current = null;
}
return () => clearInterval(pollTimerRef.current);
}, [pollActive]);
useEffect(() => {
if (pollEndingAt) {
const tick = () => {
const remaining = Math.max(0, Math.ceil((new Date(pollEndingAt).getTime() - Date.now()) / 1000));
setPollCountdown(remaining);
if (remaining <= 0) {
clearInterval(pollCountdownRef.current);
}
};
tick();
pollCountdownRef.current = setInterval(tick, 250);
return () => clearInterval(pollCountdownRef.current);
}
clearInterval(pollCountdownRef.current);
setPollCountdown(null);
}, [pollEndingAt]);
const formatElapsed = (ms) => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
const centiseconds = Math.floor((ms % 1000) / 10);
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}:${String(centiseconds).padStart(2, '0')}`;
};
const formatCountdown = (totalSeconds) => {
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${m}:${String(s).padStart(2, '0')}`;
};
const handleCreateSession = async () => {
try {
const newSession = await api.post('/sessions', {});
setActiveSession(newSession.data);
setSessionEnded(false);
setError('');
} catch (err) {
setError('Failed to create session');
}
};
const leadingGameRef = useRef(leadingGame);
leadingGameRef.current = leadingGame;
const pollActiveRef = useRef(pollActive);
pollActiveRef.current = pollActive;
const handleStartPolling = async () => {
pollStartedAtRef.current = new Date().toISOString();
setPollActive(true);
setPollResult(null);
try {
await api.post(`/sessions/${activeSession.id}/voting/start`);
} catch (err) {
console.error('Failed to start polling', err);
setPollActive(false);
pollStartedAtRef.current = null;
}
};
const handleEndPolling = async (delay = 0) => {
setShowEndPollOptions(false);
setCustomDelay('');
if (delay === 0) {
const winner = leadingGameRef.current;
setPollActive(false);
setLeadingGame(null);
setPollEndingAt(null);
if (winner) {
setPollResult(winner);
}
try {
await api.post(`/sessions/${activeSession.id}/voting/end`, { delay: 0 });
} catch (err) {
console.error('Failed to end polling', err);
setPollActive(true);
setLeadingGame(winner);
setPollResult(null);
}
} else {
try {
const res = await api.post(`/sessions/${activeSession.id}/voting/end`, { delay });
setPollEndingAt(res.data.endsAt);
} catch (err) {
console.error('Failed to schedule poll end', err);
}
}
};
const handleCancelPollEnd = async () => {
setPollEndingAt(null);
try {
await api.post(`/sessions/${activeSession.id}/voting/cancel-end`);
} catch (err) {
console.error('Failed to cancel poll end', err);
}
};
const [gameSource, setGameSource] = useState('dice');
const handleUsePollResult = () => {
if (pollResult) {
const game = allGames.find(g => g.id === pollResult.gameId);
if (game) {
setSelectedGame(game);
setGameSource('poll');
if (activeSession) {
api.post(`/sessions/${activeSession.id}/game-selection`, {
game_id: game.id,
source: 'poll'
}).catch(() => {});
}
}
}
setPollResult(null);
};
const handleIgnorePollResult = () => {
setPollResult(null);
};
const handleDismissGame = () => {
setSelectedGame(null);
setGameSource('dice');
if (activeSession) {
api.delete(`/sessions/${activeSession.id}/game-selection`).catch(() => {});
}
};
const loadEligibleGames = async () => {
try {
const params = new URLSearchParams();
params.append('enabled', 'true');
if (playerCount) {
params.append('playerCount', playerCount);
}
if (drawingFilter !== 'both') {
params.append('drawing', drawingFilter);
}
if (lengthFilter) {
params.append('length', lengthFilter);
}
if (familyFriendlyFilter) {
params.append('familyFriendly', familyFriendlyFilter);
}
let games = await api.get(`/games?${params.toString()}`);
let eligibleGamesList = games.data;
// Apply session-based exclusions if needed
if (activeSession && excludePlayedGames) {
// Get all played games in this session
const sessionGamesResponse = await api.get(`/sessions/${activeSession.id}/games`);
const playedGameIds = sessionGamesResponse.data.map(g => g.game_id);
// Filter out played games
eligibleGamesList = eligibleGamesList.filter(game => !playedGameIds.includes(game.id));
} else if (activeSession) {
// Default behavior: exclude last 2 games
const sessionGamesResponse = await api.get(`/sessions/${activeSession.id}/games`);
const recentGames = sessionGamesResponse.data.slice(-2);
const recentGameIds = recentGames.map(g => g.game_id);
eligibleGamesList = eligibleGamesList.filter(game => !recentGameIds.includes(game.id));
}
setEligibleGames(eligibleGamesList);
} catch (err) {
console.error('Failed to load eligible games', err);
}
};
const handlePickGame = async () => {
if (!activeSession) return;
setPicking(true);
setError('');
try {
const response = await api.post('/pick', {
sessionId: activeSession.id,
playerCount: playerCount ? parseInt(playerCount) : undefined,
drawing: drawingFilter !== 'both' ? drawingFilter : undefined,
length: lengthFilter || undefined,
familyFriendly: familyFriendlyFilter ? familyFriendlyFilter === 'yes' : undefined,
excludePlayed: excludePlayedGames
});
setSelectedGame(response.data.game);
setGameSource('dice');
api.post(`/sessions/${activeSession.id}/game-selection`, {
game_id: response.data.game.id,
source: 'dice'
}).catch(() => {});
} catch (err) {
setError(err.response?.data?.error || 'Failed to pick a game');
setSelectedGame(null);
setGameSource('dice');
} finally {
setPicking(false);
}
};
const handleAcceptGame = async () => {
if (!selectedGame || !activeSession) return;
// Show room code modal
setPendingGameAction({
type: 'accept',
game: selectedGame,
source: gameSource
});
setShowRoomCodeModal(true);
};
const handleRoomCodeConfirm = async (roomCode) => {
if (!pendingGameAction || !activeSession) return;
try {
const { type, game, gameId, source } = pendingGameAction;
if (type === 'accept' || type === 'version') {
const response = await api.post(`/sessions/${activeSession.id}/games`, {
game_id: gameId || game.id,
manually_added: false,
room_code: roomCode,
source: source || 'dice'
});
// Set the newly added game as playing
setPlayingGame(response.data);
} else if (type === 'manual') {
const response = await api.post(`/sessions/${activeSession.id}/games`, {
game_id: gameId,
manually_added: true,
room_code: roomCode,
source: 'manual'
});
setManualGameId('');
setShowManualSelect(false);
// Set the newly added game as playing
setPlayingGame(response.data);
}
// Close all modals and clear selected game after adding to session
setSelectedGame(null);
setGameSource('dice');
setShowGamePool(false);
// Trigger games list refresh
setGamesUpdateTrigger(prev => prev + 1);
setError('');
} catch (err) {
setError('Failed to add game to session');
} finally {
setShowRoomCodeModal(false);
setPendingGameAction(null);
}
};
const handleRoomCodeCancel = () => {
setShowRoomCodeModal(false);
setPendingGameAction(null);
};
const handleAddManualGame = async () => {
if (!manualGameId || !activeSession) return;
// Show room code modal
const game = allGames.find(g => g.id === parseInt(manualGameId));
setPendingGameAction({
type: 'manual',
gameId: parseInt(manualGameId),
game: game
});
setShowRoomCodeModal(true);
// Reset search
setManualSearchQuery('');
setShowManualDropdown(false);
setManualGameId('');
};
// Handle manual search input with filtering
const handleManualSearchChange = useCallback((e) => {
const query = e.target.value;
setManualSearchQuery(query);
if (query.trim().length === 0) {
setFilteredManualGames([]);
setShowManualDropdown(false);
setManualGameId('');
return;
}
// Filter games by query (non-blocking)
const lowerQuery = query.toLowerCase();
const filtered = allGames.filter(game =>
game.title.toLowerCase().includes(lowerQuery) ||
game.pack_name.toLowerCase().includes(lowerQuery)
).slice(0, 50); // Limit to 50 results for performance
setFilteredManualGames(filtered);
setShowManualDropdown(filtered.length > 0);
}, [allGames]);
// Handle selecting a game from the dropdown
const handleSelectManualGame = useCallback((game) => {
setManualGameId(game.id.toString());
setManualSearchQuery(`${game.title} (${game.pack_name})`);
setShowManualDropdown(false);
}, []);
const handleSelectVersion = async (gameId) => {
if (!activeSession) return;
// Show room code modal
const game = allGames.find(g => g.id === gameId);
setPendingGameAction({
type: 'version',
gameId: gameId,
game: game
});
setShowRoomCodeModal(true);
};
// Find similar versions of a game based on title patterns
const findSimilarVersions = (game) => {
if (!game) return [];
// Extract base name by removing common version patterns
const baseName = game.title
.replace(/\s*\d+$/, '') // Remove trailing numbers (e.g., "Game 2" -> "Game")
.replace(/\s*:\s*.*$/, '') // Remove subtitle after colon
.replace(/\s*\(.*?\)$/, '') // Remove parenthetical
.trim();
// Find games with similar base names (but not the exact same game)
return allGames.filter(g => {
if (g.id === game.id) return false; // Exclude the current game
if (!g.enabled) return false; // Only show enabled games
const otherBaseName = g.title
.replace(/\s*\d+$/, '')
.replace(/\s*:\s*.*$/, '')
.replace(/\s*\(.*?\)$/, '')
.trim();
// Match if base names are the same (case insensitive)
return otherBaseName.toLowerCase() === baseName.toLowerCase();
});
};
const similarVersions = React.useMemo(() => {
return findSimilarVersions(selectedGame);
}, [selectedGame, allGames]);
if (authLoading || loading) {
return (
);
}
if (!activeSession) {
return (
{sessionEnded ? (
<>
⚠️ Session Ended
The active session has been ended. To continue picking games, you'll need to create a new session.
>
) : (
<>
No Active Session
There is no active game session. Create a new session to start picking games.
>
)}
);
}
return (
Game Picker
{/* Picker Controls Panel */}
{/* Main Action Buttons - Above filters on mobile */}
{/* Exclude played games checkbox */}
{/* Filters */}
{/* Mobile: Collapsible header */}
{/* Desktop: Always show title */}
Filters
{/* Filter content - collapsible on mobile */}
{/* Compact Toggle Filters */}
{/* Game Pool Modal */}
{showGamePool && (
setShowGamePool(false)}
/>
)}
{/* Room Code Modal */}
{/* Results Panel */}
{error && (
{error}
)}
{/* Poll Leader Indicator */}
{leadingGame && (
Poll Leader
{leadingGame.label}
{leadingGame.votes} votes
)}
{/* Currently Playing Game Card */}
{playingGame && (
🎮 Playing Now
{playingGame.title}
{playingGame.pack_name}
Players:
{playingGame.min_players}-{playingGame.max_players}
Length:
{playingGame.length_minutes ? `${playingGame.length_minutes} min` : 'Unknown'}
Type:
{playingGame.game_type || 'N/A'}
Room Code:
{playingGame.room_code || 'N/A'}
)}
{/* Poll Control Card */}
{pollResult ? (
Poll Winner: {pollResult.label}
{pollResult.votes} votes — Use as the next game?
) : pollActive && pollEndingAt ? (
Poll Ending
Voting will close automatically.
Ending in
{pollCountdown !== null ? formatCountdown(pollCountdown) : '--:--'}
) : pollActive ? (
Voting In Progress
End the current poll when ready to pick the next game.
{showEndPollOptions && (
{[5, 10, 30].map(s => (
))}
setCustomDelay(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
const val = parseInt(customDelay);
if (val >= 1 && val <= 300) handleEndPolling(val);
}
}}
className="w-20 px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
max 5m
)}
) : (
Ready to Vote
Start a new poll for the next game.
)}
{/* Selected Game Card (from dice roll) */}
{selectedGame && (
{/* Close/Dismiss Button */}
{selectedGame.title}
{selectedGame.pack_name}
Players:
{selectedGame.min_players}-{selectedGame.max_players}
Length:
{selectedGame.length_minutes ? `${selectedGame.length_minutes} min` : 'Unknown'}
Type:
{selectedGame.game_type || 'N/A'}
Family Friendly:
{selectedGame.family_friendly ? 'Yes' : 'No'}
Play Count:
{selectedGame.play_count}
{/* Other Versions Suggestion */}
{similarVersions.length > 0 && (
🔄 Other Versions Available
This game has multiple versions. You can choose a different one:
{similarVersions.map((version) => (
))}
)}
)}
{showManualSelect && (
Manual Game Selection
{
if (filteredManualGames.length > 0) {
setShowManualDropdown(true);
}
}}
placeholder="Type to search games..."
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400"
/>
{/* Autocomplete dropdown - above on mobile, below on desktop */}
{showManualDropdown && filteredManualGames.length > 0 && (
{filteredManualGames.map((game) => (
))}
)}
{/* No results message - above on mobile, below on desktop */}
{manualSearchQuery.trim() && filteredManualGames.length === 0 && !showManualDropdown && (
No games found matching "{manualSearchQuery}"
)}
)}
{/* Session info and games */}
);
}
function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame, setHasPlayedGames, setLeadingGame, setPollActive, pollActiveRef, setPollResult, pollStartedAtRef, setSelectedGame, setGameSource }) {
const { isAuthenticated, token } = useAuth();
const [games, setGames] = useState([]);
const [loading, setLoading] = useState(true);
const [confirmingRemove, setConfirmingRemove] = useState(null);
const [showPopularity, setShowPopularity] = useState(true);
const [editingRoomCode, setEditingRoomCode] = useState(null);
const [newRoomCode, setNewRoomCode] = useState('');
const [showRepeatRoomCodeModal, setShowRepeatRoomCodeModal] = useState(false);
const [repeatGameData, setRepeatGameData] = useState(null);
const [wsConnection, setWsConnection] = useState(null);
const [editingPlayerCount, setEditingPlayerCount] = useState(null);
const [newPlayerCount, setNewPlayerCount] = useState('');
const playingGameRef = useRef(playingGame);
playingGameRef.current = playingGame;
const loadGames = useCallback(async () => {
try {
const response = await api.get(`/sessions/${sessionId}/games`);
const freshGames = response.data;
setGames(freshGames.reverse());
const currentPlaying = freshGames.find(g => g.status === 'playing');
const prev = playingGameRef.current;
if (currentPlaying) {
if (!prev || prev.id !== currentPlaying.id || prev.player_count !== currentPlaying.player_count) {
setPlayingGame(currentPlaying);
}
} else if (prev) {
const still = freshGames.find(g => g.id === prev.id);
if (!still || still.status !== 'playing') {
setPlayingGame(null);
}
}
setHasPlayedGames(freshGames.some(g => g.status === 'played'));
} catch (err) {
console.error('Failed to load session games');
} finally {
setLoading(false);
}
}, [sessionId, setPlayingGame]);
useEffect(() => {
loadGames();
}, [sessionId, onGamesUpdate, loadGames]);
// Fallback polling — WebSocket events handle most updates; this is a safety net
useEffect(() => {
const interval = setInterval(() => {
loadGames();
}, 60000);
return () => clearInterval(interval);
}, [loadGames]);
// Setup WebSocket connection for real-time session updates (with ping + auto-reconnect)
const wsRef = useRef(null);
const pingIntervalRef = useRef(null);
const reconnectTimeoutRef = useRef(null);
const connectWs = useCallback(() => {
if (!token) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.hostname}:${window.location.port || (window.location.protocol === 'https:' ? 443 : 80)}/api/sessions/live`;
try {
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
console.log('[WebSocket] Connected, authenticating...');
ws.send(JSON.stringify({ type: 'auth', token }));
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'auth_success') {
console.log('[WebSocket] Authenticated, subscribing to session', sessionId);
ws.send(JSON.stringify({ type: 'subscribe', sessionId: parseInt(sessionId) }));
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
return;
}
const reloadEvents = [
'room.connected',
'lobby.player-joined',
'lobby.updated',
'game.started',
'game.ended',
'room.disconnected',
'player-count.updated',
'game.added',
'game.status',
];
if (message.type === 'poll.start') {
pollStartedAtRef.current = message.data.pollStartedAt || new Date().toISOString();
setPollActive(true);
setPollResult(null);
setLeadingGame(null);
setPollEndingAt(null);
setShowEndPollOptions(false);
return;
}
if (message.type === 'poll.ending') {
setPollEndingAt(message.data.endsAt);
setShowEndPollOptions(false);
return;
}
if (message.type === 'poll.ending.cancelled') {
setPollEndingAt(null);
return;
}
if (message.type === 'poll.leading') {
if (pollActiveRef.current) {
setLeadingGame(message.data);
}
return;
}
if (message.type === 'voting.ended') {
setLeadingGame(null);
setPollActive(false);
setPollEndingAt(null);
setShowEndPollOptions(false);
if (message.data.winnerGameId) {
setPollResult({
gameId: message.data.winnerGameId,
label: message.data.winnerLabel,
votes: message.data.winnerVotes
});
}
}
if (message.type === 'game.started') {
setLeadingGame(null);
setPollActive(false);
setPollEndingAt(null);
}
if (message.type === 'game.picked') {
setSelectedGame(message.data.game);
setGameSource(message.data.source || 'dice');
return;
}
if (message.type === 'game.dismissed') {
setSelectedGame(null);
setGameSource('dice');
return;
}
if (reloadEvents.includes(message.type)) {
console.log(`[WebSocket] ${message.type}:`, message.data);
if (message.type === 'game.added') {
setSelectedGame(null);
setGameSource('dice');
}
loadGames();
}
} catch (error) {
console.error('[WebSocket] Error parsing message:', error);
}
};
ws.onerror = (error) => {
console.error('[WebSocket] Error:', error);
};
ws.onclose = () => {
console.log('[WebSocket] Disconnected, reconnecting in 3s...');
clearInterval(pingIntervalRef.current);
reconnectTimeoutRef.current = setTimeout(connectWs, 3000);
};
setWsConnection(ws);
} catch (error) {
console.error('[WebSocket] Failed to connect:', error);
reconnectTimeoutRef.current = setTimeout(connectWs, 3000);
}
}, [sessionId, token, loadGames, setPollActive, setPollResult, setLeadingGame, pollActiveRef, pollStartedAtRef, setSelectedGame, setGameSource]);
useEffect(() => {
connectWs();
return () => {
clearTimeout(reconnectTimeoutRef.current);
clearInterval(pingIntervalRef.current);
if (wsRef.current) {
wsRef.current.onclose = null;
wsRef.current.close();
}
};
}, [connectWs]);
const handleUpdateStatus = async (gameId, newStatus) => {
try {
await api.patch(`/sessions/${sessionId}/games/${gameId}/status`, { status: newStatus });
// If we're changing the playing game's status, clear it from the playing card
if (playingGame && playingGame.id === gameId && newStatus !== 'playing') {
setPlayingGame(null);
}
loadGames(); // Reload to get updated statuses
} catch (err) {
console.error('Failed to update game status', err);
}
};
const handleRemoveClick = (gameId) => {
if (confirmingRemove === gameId) {
// Second click - actually remove
handleRemoveGame(gameId);
} else {
// First click - show confirmation
setConfirmingRemove(gameId);
// Reset after 3 seconds
setTimeout(() => setConfirmingRemove(null), 3000);
}
};
const handleRemoveGame = async (gameId) => {
try {
await api.delete(`/sessions/${sessionId}/games/${gameId}`);
// If we're removing the playing game, clear it from the playing card
if (playingGame && playingGame.id === gameId) {
setPlayingGame(null);
}
setConfirmingRemove(null);
loadGames(); // Reload after deletion
} catch (err) {
console.error('Failed to remove game', err);
setConfirmingRemove(null);
}
};
const handleEditRoomCode = (gameId, currentCode) => {
setEditingRoomCode(gameId);
setNewRoomCode(currentCode || '');
};
const handleRoomCodeChange = (e) => {
const value = e.target.value.toUpperCase();
const filtered = value.replace(/[^A-Z0-9]/g, '').slice(0, 4);
setNewRoomCode(filtered);
};
const handleSaveRoomCode = async (gameId) => {
if (newRoomCode.length !== 4) {
return;
}
try {
await api.patch(`/sessions/${sessionId}/games/${gameId}/room-code`, {
room_code: newRoomCode
});
setEditingRoomCode(null);
setNewRoomCode('');
loadGames(); // Reload to show updated code
} catch (err) {
console.error('Failed to update room code', err);
}
};
const handleCancelEditRoomCode = () => {
setEditingRoomCode(null);
setNewRoomCode('');
};
const handleRepeatGame = (game) => {
// Store the game data and open the room code modal
setRepeatGameData(game);
setShowRepeatRoomCodeModal(true);
};
const handleRepeatRoomCodeConfirm = async (roomCode) => {
if (!repeatGameData) return;
try {
const response = await api.post(`/sessions/${sessionId}/games`, {
game_id: repeatGameData.game_id,
manually_added: false,
room_code: roomCode
});
// Set the newly added game as playing
setPlayingGame(response.data);
setShowRepeatRoomCodeModal(false);
setRepeatGameData(null);
loadGames(); // Reload to show the new game
} catch (err) {
console.error('Failed to repeat game', err);
}
};
const handleRepeatRoomCodeCancel = () => {
setShowRepeatRoomCodeModal(false);
setRepeatGameData(null);
};
const handleStopPlayerCountCheck = async (gameId) => {
try {
await api.post(`/sessions/${sessionId}/games/${gameId}/stop-player-check`);
loadGames(); // Reload to show updated status
} catch (err) {
console.error('Failed to stop player count check', err);
}
};
const handleRetryPlayerCount = async (gameId, roomCode) => {
if (!roomCode) return;
try {
await api.post(`/sessions/${sessionId}/games/${gameId}/start-player-check`);
loadGames(); // Reload to show checking status
} catch (err) {
console.error('Failed to start player count check', err);
}
};
const handleEditPlayerCount = (gameId, currentCount) => {
setEditingPlayerCount(gameId);
setNewPlayerCount(currentCount?.toString() || '');
};
const handleSavePlayerCount = async (gameId) => {
const count = parseInt(newPlayerCount);
if (isNaN(count) || count < 0) {
return;
}
try {
await api.patch(`/sessions/${sessionId}/games/${gameId}/player-count`, {
player_count: count
});
setEditingPlayerCount(null);
setNewPlayerCount('');
loadGames(); // Reload to show updated count
} catch (err) {
console.error('Failed to update player count', err);
}
};
const handleCancelEditPlayerCount = () => {
setEditingPlayerCount(null);
setNewPlayerCount('');
};
const getStatusBadge = (status) => {
if (status === 'playing') {
return (
🎮 Playing
);
}
if (status === 'skipped') {
return (
⏭️ Skipped
);
}
return null;
};
return (
<>
{/* Room Code Modal for Repeat Game */}
Games Played This Session ({games.length})
{loading ? (
Loading...
) : games.length === 0 ? (
No games played yet. Pick a game to get started!
) : (
{games.map((game, index) => {
const displayNumber = games.length - index;
return (
{displayNumber}. {game.title}
{getStatusBadge(game.status)}
{game.source === 'manual' || (game.manually_added === 1 && game.source !== 'poll') ? (
Manual
) : null}
{game.source === 'poll' && (
Poll
)}
{game.room_code && (
{editingRoomCode === game.id ? (
) : (
<>
🎮 {game.room_code}
{isAuthenticated && (
)}
>
)}
{/* Player Count Display */}
{game.player_count_check_status && game.player_count_check_status !== 'not_started' && (
{game.player_count_check_status === 'monitoring' && !game.player_count && (
📡 Monitoring...
)}
{(game.player_count_check_status === 'checking' || (game.player_count_check_status === 'monitoring' && game.player_count)) && (
📡 {game.player_count ? `${game.player_count} players` : 'Monitoring...'}
)}
{game.player_count_check_status === 'completed' && game.player_count && (
<>
{editingPlayerCount === game.id ? (
setNewPlayerCount(e.target.value)}
className="w-12 px-2 py-1 text-xs text-center border border-green-400 dark:border-green-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-green-500"
min="0"
autoFocus
/>
) : (
<>
✓ {game.player_count} players
{isAuthenticated && (
)}
>
)}
>
)}
{game.player_count_check_status === 'failed' && (
<>
{editingPlayerCount === game.id ? (
setNewPlayerCount(e.target.value)}
className="w-12 px-2 py-1 text-xs text-center border border-orange-400 dark:border-orange-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-orange-500"
min="0"
autoFocus
/>
) : (
<>
{isAuthenticated && (
)}
>
)}
>
)}
{/* Stop button for active checks */}
{isAuthenticated && (game.player_count_check_status === 'monitoring' || game.player_count_check_status === 'checking') && (
)}
)}
)}
{showPopularity && (
)}
{game.pack_name} • {formatLocalTime(game.played_at)}
{/* Action buttons for admins */}
{isAuthenticated && (
{game.status !== 'playing' && (
)}
{game.status === 'playing' && (
)}
{game.status !== 'skipped' && (
)}
)}
);
})}
)}
>
);
}
export default Picker;