Files
jackboxpartypack-gamepicker/frontend/src/pages/Picker.jsx
cottongin 59db8f6ed7 feat: poll countdown timer, game-selection sync, source tracking, and multi-admin fixes
Work spanning May 7-10 across multiple sessions:

Poll winner detection + source column (May 7):
- Fix race condition in handleEndPolling where WS voting.ended cleared
  leadingGame before the setTimeout could capture the winner
- Add pollActiveRef guard to prevent late poll.leading messages from
  re-activating an ended poll
- Add 'source' column to session_games (dice/manual/poll) with backward-
  compatible fallback from manually_added flag
- Show indigo "Poll" badge in game lists (Picker, Home, SessionDetail)
- Include source in session export (JSON and text formats)

Multi-admin poll state sync (May 9):
- Enrich poll.start broadcast with pollStartedAt timestamp so all admin
  clients can start their timers from the correct time
- Enrich voting.ended broadcast with winnerGameId/Label/Votes so all
  admins see the winner prompt, not just the one who clicked End Poll
- Add poll.start WS handler in SessionInfo so Admin B sees polls started
  by Admin A without refreshing
- Make handleStartPolling optimistic with rollback on failure

WebSocket keepalive + auto-reconnect (May 9):
- Add 30s ping interval to SessionInfo WS connection (matching server's
  60s timeout) to prevent silent disconnects
- Add auto-reconnect on close with 3s delay
- Proper cleanup of ping interval, reconnect timeout, and onclose handler

Sync selected game across admin clients (May 10):
- New POST/DELETE /sessions/:id/game-selection endpoints with DB
  persistence (pending_game_id, pending_game_source columns)
- Broadcast game.picked/game.dismissed WS events to session subscribers
- handleDismissGame replaces inline setSelectedGame(null) calls
- Restore pending game selection on page load for late-joining admins
- Clear pending selection when game is formally added to session

Poll ending countdown timer (May 10):
- POST /:id/voting/end now accepts optional { delay } (0-300 seconds)
- New POST /:id/voting/cancel-end to abort a scheduled end
- New poll.ending and poll.ending.cancelled WS events
- poll_ending_at column on sessions table for crash recovery
- rescheduleEndingPolls() called on server startup to resume countdowns
- End Poll button opens popover with End Now / 5s / 10s / 30s / custom
- Red "Poll Ending" card with countdown display and Cancel button
- Document new WS events in docs/api/websocket.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 20:33:00 -04:00

1980 lines
83 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="flex justify-center items-center h-64">
<div className="text-xl text-gray-600 dark:text-gray-400">Loading...</div>
</div>
);
}
if (!activeSession) {
return (
<div className="max-w-4xl mx-auto">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
{sessionEnded ? (
<>
<h2 className="text-2xl font-bold text-orange-600 dark:text-orange-400 mb-4">
Session Ended
</h2>
<p className="text-gray-700 dark:text-gray-300 mb-4">
The active session has been ended. To continue picking games, you'll need to create a new session.
</p>
<button
onClick={handleCreateSession}
className="bg-indigo-600 dark:bg-indigo-700 text-white px-6 py-3 rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 transition font-semibold"
>
Create New Session
</button>
</>
) : (
<>
<h2 className="text-2xl font-bold text-gray-700 dark:text-gray-200 mb-4">
No Active Session
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
There is no active game session. Create a new session to start picking games.
</p>
<button
onClick={handleCreateSession}
className="bg-indigo-600 dark:bg-indigo-700 text-white px-6 py-3 rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 transition font-semibold"
>
Create New Session
</button>
</>
)}
</div>
</div>
);
}
return (
<div className="max-w-6xl mx-auto">
<h1 className="text-2xl sm:text-4xl font-bold mb-4 sm:mb-8 text-gray-800 dark:text-gray-100">Game Picker</h1>
<div className="grid md:grid-cols-3 gap-4 sm:gap-6">
{/* Picker Controls Panel */}
<div className="md:col-span-1 space-y-4">
{/* Main Action Buttons - Above filters on mobile */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4">
<button
onClick={handlePickGame}
disabled={picking}
className="w-full bg-indigo-600 text-white py-4 rounded-lg hover:bg-indigo-700 transition disabled:bg-gray-400 dark:disabled:bg-gray-600 disabled:cursor-not-allowed font-bold text-xl mb-3"
>
{picking ? 'Rolling...' : '🎲 Roll the Dice'}
</button>
{/* Exclude played games checkbox */}
<label className="flex items-center gap-3 p-3 mb-4 cursor-pointer group bg-gray-50 dark:bg-gray-700/30 hover:bg-gray-100 dark:hover:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 transition">
<div className="relative flex items-center">
<input
type="checkbox"
checked={excludePlayedGames}
onChange={(e) => setExcludePlayedGames(e.target.checked)}
className="w-5 h-5 rounded border-2 border-gray-300 dark:border-gray-500 text-indigo-600 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 bg-white dark:bg-gray-800 cursor-pointer transition checked:border-indigo-600 dark:checked:border-indigo-500 checked:bg-indigo-600 dark:checked:bg-indigo-600"
/>
</div>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 transition select-none flex-1">
Only select from unplayed games
</span>
</label>
<div className="space-y-4 sm:space-y-2">
<div className="grid grid-cols-2 gap-4 sm:gap-2">
<button
onClick={() => setShowManualSelect(!showManualSelect)}
className="bg-gray-600 dark:bg-gray-700 text-white py-3 sm:py-2 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition text-xs sm:text-sm"
>
{showManualSelect ? 'Cancel' : 'Manual'}
</button>
<button
onClick={async () => {
await loadEligibleGames();
setShowGamePool(true);
}}
className="bg-blue-600 dark:bg-blue-700 text-white py-3 sm:py-2 rounded-lg hover:bg-blue-700 dark:hover:bg-blue-800 transition text-xs sm:text-sm"
>
View Pool
</button>
</div>
<button
onClick={() => navigate('/history')}
className="w-full bg-indigo-300 dark:bg-indigo-400 text-gray-900 dark:text-gray-900 py-3 sm:py-2 rounded-lg hover:bg-indigo-400 dark:hover:bg-indigo-500 transition text-xs sm:text-sm font-medium"
>
Go to Session Manager
</button>
</div>
</div>
{/* Filters */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden">
{/* Mobile: Collapsible header */}
<button
onClick={() => setShowFilters(!showFilters)}
className="w-full md:hidden flex justify-between items-center p-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700/50 transition"
>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Filters {playerCount || lengthFilter || drawingFilter !== 'both' || familyFriendlyFilter ? '(Active)' : ''}
</span>
<span className="text-gray-500 dark:text-gray-400">
{showFilters ? '' : ''}
</span>
</button>
{/* Desktop: Always show title */}
<div className="hidden md:block p-4 pb-0">
<h2 className="text-lg font-semibold mb-3 text-gray-800 dark:text-gray-100">Filters</h2>
</div>
{/* Filter content - collapsible on mobile */}
<div className={`${showFilters ? 'block' : 'hidden'} md:block p-4 space-y-3`}>
<div>
<label className="block text-sm text-gray-700 dark:text-gray-300 font-semibold mb-1">
Player Count
</label>
<div className="flex items-center gap-2">
<button
onClick={() => {
const current = parseInt(playerCount) || 0;
if (current > 1) {
setPlayerCount(String(current - 1));
} else if (!playerCount) {
setPlayerCount('3');
}
}}
className="w-10 h-10 sm:w-8 sm:h-8 flex items-center justify-center bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg transition font-bold text-lg disabled:opacity-50 disabled:cursor-not-allowed"
disabled={playerCount && parseInt(playerCount) <= 1}
title="Decrease"
>
</button>
<div className="flex-1 relative">
<input
type="number"
min="1"
max="100"
value={playerCount}
onChange={(e) => setPlayerCount(e.target.value)}
className="w-full px-3 py-2 pr-8 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 text-sm text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
placeholder="Any"
/>
{playerCount && (
<button
onClick={() => setPlayerCount('')}
className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition"
title="Clear filter"
>
×
</button>
)}
</div>
<button
onClick={() => {
const current = parseInt(playerCount) || 0;
if (current > 0 && current < 100) {
setPlayerCount(String(current + 1));
} else if (!playerCount || current === 0) {
setPlayerCount('3');
}
}}
className="w-10 h-10 sm:w-8 sm:h-8 flex items-center justify-center bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg transition font-bold text-lg disabled:opacity-50 disabled:cursor-not-allowed"
disabled={playerCount && parseInt(playerCount) >= 100}
title="Increase"
>
+
</button>
</div>
</div>
<div>
<label className="block text-sm text-gray-700 dark:text-gray-300 font-semibold mb-1">
Game Length
</label>
<select
value={lengthFilter}
onChange={(e) => setLengthFilter(e.target.value)}
className="w-full px-3 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 text-sm"
>
<option value="">Any</option>
<option value="short">Short &lt;15 min</option>
<option value="medium">Medium (16-25 min)</option>
<option value="long">Long &gt;25 min</option>
</select>
</div>
{/* Compact Toggle Filters */}
<div className="grid grid-cols-2 gap-3 sm:gap-2">
<div>
<label className="block text-xs text-gray-700 dark:text-gray-300 font-semibold mb-1">
Drawing
</label>
<div className="flex border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
<button
onClick={() => setDrawingFilter('exclude')}
className={`flex-1 py-2 sm:py-1.5 text-xs transition ${
drawingFilter === 'exclude'
? 'bg-red-500 text-white'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
No
</button>
<button
onClick={() => setDrawingFilter('both')}
className={`flex-1 py-2 sm:py-1.5 text-xs border-x border-gray-300 dark:border-gray-600 transition ${
drawingFilter === 'both'
? 'bg-gray-500 text-white'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
Any
</button>
<button
onClick={() => setDrawingFilter('only')}
className={`flex-1 py-2 sm:py-1.5 text-xs transition ${
drawingFilter === 'only'
? 'bg-green-500 text-white'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
Yes
</button>
</div>
</div>
<div>
<label className="block text-xs text-gray-700 dark:text-gray-300 font-semibold mb-1">
Family Friendly
</label>
<div className="flex border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
<button
onClick={() => setFamilyFriendlyFilter('no')}
className={`flex-1 py-2 sm:py-1.5 text-xs transition ${
familyFriendlyFilter === 'no'
? 'bg-red-500 text-white'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
No
</button>
<button
onClick={() => setFamilyFriendlyFilter('')}
className={`flex-1 py-2 sm:py-1.5 text-xs border-x border-gray-300 dark:border-gray-600 transition ${
familyFriendlyFilter === ''
? 'bg-gray-500 text-white'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
Any
</button>
<button
onClick={() => setFamilyFriendlyFilter('yes')}
className={`flex-1 py-2 sm:py-1.5 text-xs transition ${
familyFriendlyFilter === 'yes'
? 'bg-green-500 text-white'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
Yes
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Game Pool Modal */}
{showGamePool && (
<GamePoolModal
games={eligibleGames}
onClose={() => setShowGamePool(false)}
/>
)}
{/* Room Code Modal */}
<RoomCodeModal
isOpen={showRoomCodeModal}
onConfirm={handleRoomCodeConfirm}
onCancel={handleRoomCodeCancel}
gameName={pendingGameAction?.game?.title}
/>
{/* Results Panel */}
<div className="md:col-span-2">
{error && (
<div className="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-200 p-4 rounded mb-4">
{error}
</div>
)}
{/* Poll Leader Indicator */}
{leadingGame && (
<div className="bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800 rounded-lg p-3 sm:p-4 mb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-indigo-500 dark:text-indigo-400 uppercase tracking-wide">
Poll Leader
</span>
<span className="text-sm font-semibold text-indigo-800 dark:text-indigo-200">
{leadingGame.label}
</span>
</div>
<span className="text-xs text-indigo-500 dark:text-indigo-400">
{leadingGame.votes} votes
</span>
</div>
</div>
)}
{/* Currently Playing Game Card */}
{playingGame && (
<div className="bg-green-50 dark:bg-green-900/20 border-2 border-green-500 dark:border-green-700 rounded-lg shadow-lg p-4 sm:p-8 mb-6">
<div className="flex items-center gap-2 mb-4">
<span className="inline-flex items-center gap-1 text-sm bg-green-600 dark:bg-green-700 text-white px-3 py-1 rounded-full font-semibold">
🎮 Playing Now
</span>
</div>
<h2 className="text-2xl sm:text-3xl font-bold mb-4 text-gray-800 dark:text-gray-100">
{playingGame.title}
</h2>
<p className="text-lg sm:text-xl text-gray-600 dark:text-gray-400 mb-4">{playingGame.pack_name}</p>
<div className="grid grid-cols-2 gap-3 sm:gap-4 mb-6 text-sm sm:text-base">
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Players:</span>
<span className="ml-2 text-gray-600 dark:text-gray-400">
{playingGame.min_players}-{playingGame.max_players}
</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Length:</span>
<span className="ml-2 text-gray-600 dark:text-gray-400">
{playingGame.length_minutes ? `${playingGame.length_minutes} min` : 'Unknown'}
</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Type:</span>
<span className="ml-2 text-gray-600 dark:text-gray-400">
{playingGame.game_type || 'N/A'}
</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Room Code:</span>
<span className="ml-2 text-gray-600 dark:text-gray-400 font-mono font-bold">
{playingGame.room_code || 'N/A'}
</span>
</div>
</div>
<div className="flex gap-4">
<button
disabled
className="flex-1 bg-green-600 dark:bg-green-700 text-white py-3 rounded-lg opacity-70 cursor-not-allowed font-semibold"
>
✓ Playing
</button>
<button
onClick={handlePickGame}
className="flex-1 bg-yellow-600 dark:bg-yellow-700 text-white py-3 rounded-lg hover:bg-yellow-700 dark:hover:bg-yellow-800 transition font-semibold"
>
🎲 Pick New Game
</button>
</div>
</div>
)}
{/* Poll Control Card */}
{pollResult ? (
<div className="bg-indigo-50 dark:bg-indigo-900/20 border-2 border-indigo-400 dark:border-indigo-700 rounded-lg shadow-lg p-4 sm:p-6 mb-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-indigo-800 dark:text-indigo-200">
Poll Winner: {pollResult.label}
</h3>
<p className="text-sm text-indigo-600 dark:text-indigo-400 mt-1">
{pollResult.votes} votes — Use as the next game?
</p>
</div>
<div className="flex gap-2">
<button
onClick={handleUsePollResult}
className="bg-green-600 dark:bg-green-700 text-white px-4 py-2 rounded-lg hover:bg-green-700 dark:hover:bg-green-800 transition font-semibold text-sm whitespace-nowrap"
>
Use as Choice
</button>
<button
onClick={handleIgnorePollResult}
className="bg-gray-500 dark:bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-600 dark:hover:bg-gray-700 transition font-semibold text-sm whitespace-nowrap"
>
Ignore
</button>
</div>
</div>
</div>
) : pollActive && pollEndingAt ? (
<div className="bg-red-50 dark:bg-red-900/20 border-2 border-red-400 dark:border-red-700 rounded-lg shadow-lg p-4 sm:p-6 mb-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-red-800 dark:text-red-200">
Poll Ending
</h3>
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
Voting will close automatically.
</p>
</div>
<div className="flex items-center gap-3">
<div className="flex flex-col items-center">
<span className="text-sm text-red-700 dark:text-red-300 font-semibold">Ending in</span>
<span className="font-mono text-2xl tracking-wider text-red-800 dark:text-red-100 bg-red-200/60 dark:bg-red-800/40 px-4 py-2 rounded" style={{ fontFamily: "'Courier New', monospace" }}>
{pollCountdown !== null ? formatCountdown(pollCountdown) : '--:--'}
</span>
</div>
<button
onClick={handleCancelPollEnd}
className="bg-gray-500 dark:bg-gray-600 text-white px-4 py-3 rounded-lg hover:bg-gray-600 dark:hover:bg-gray-700 transition font-semibold text-sm whitespace-nowrap"
>
Cancel
</button>
</div>
</div>
</div>
) : pollActive ? (
<div className="bg-orange-50 dark:bg-orange-900/20 border-2 border-orange-400 dark:border-orange-700 rounded-lg shadow-lg p-4 sm:p-6 mb-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-orange-800 dark:text-orange-200">
Voting In Progress
</h3>
<p className="text-sm text-orange-600 dark:text-orange-400 mt-1">
End the current poll when ready to pick the next game.
</p>
</div>
<div className="relative">
<button
onClick={() => setShowEndPollOptions(prev => !prev)}
className="bg-orange-600 dark:bg-orange-700 text-white px-6 py-4 rounded-lg hover:bg-orange-700 dark:hover:bg-orange-800 transition font-semibold whitespace-nowrap flex flex-col items-center gap-1"
>
<span className="text-sm">End Poll</span>
<span className="font-mono text-lg tracking-wider bg-black/20 px-3 py-1 rounded" style={{ fontFamily: "'Courier New', monospace" }}>
{formatElapsed(pollElapsed)}
</span>
</button>
{showEndPollOptions && (
<div className="absolute right-0 top-full mt-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl z-50 w-56 py-1">
<button
onClick={() => handleEndPolling(0)}
className="w-full text-left px-4 py-2.5 text-sm font-semibold text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition"
>
End Now
</button>
<hr className="border-gray-200 dark:border-gray-700 my-1" />
{[5, 10, 30].map(s => (
<button
key={s}
onClick={() => handleEndPolling(s)}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition"
>
{s} seconds
</button>
))}
<hr className="border-gray-200 dark:border-gray-700 my-1" />
<div className="px-4 py-2 flex items-center gap-2">
<input
type="number"
min="1"
max="300"
placeholder="sec"
value={customDelay}
onChange={e => 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"
/>
<button
onClick={() => {
const val = parseInt(customDelay);
if (val >= 1 && val <= 300) handleEndPolling(val);
}}
disabled={!customDelay || parseInt(customDelay) < 1 || parseInt(customDelay) > 300}
className="px-3 py-1.5 text-sm bg-orange-600 dark:bg-orange-700 text-white rounded hover:bg-orange-700 dark:hover:bg-orange-800 transition disabled:opacity-40 disabled:cursor-not-allowed font-semibold"
>
Go
</button>
<span className="text-xs text-gray-400">max 5m</span>
</div>
</div>
)}
</div>
</div>
</div>
) : (
<div className="bg-green-50 dark:bg-green-900/20 border-2 border-green-400 dark:border-green-700 rounded-lg shadow-lg p-4 sm:p-6 mb-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-green-800 dark:text-green-200">
Ready to Vote
</h3>
<p className="text-sm text-green-600 dark:text-green-400 mt-1">
Start a new poll for the next game.
</p>
</div>
<button
onClick={handleStartPolling}
className="bg-green-600 dark:bg-green-700 text-white px-5 py-3 rounded-lg hover:bg-green-700 dark:hover:bg-green-800 transition font-semibold text-sm whitespace-nowrap"
>
Start Poll
</button>
</div>
</div>
)}
{/* Selected Game Card (from dice roll) */}
{selectedGame && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-8 mb-6 relative">
{/* Close/Dismiss Button */}
<button
onClick={handleDismissGame}
className="absolute top-2 right-2 sm:top-4 sm:right-4 w-8 h-8 flex items-center justify-center text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition"
title="Dismiss"
>
</button>
<h2 className="text-2xl sm:text-3xl font-bold mb-4 text-gray-800 dark:text-gray-100 pr-8">
{selectedGame.title}
</h2>
<p className="text-lg sm:text-xl text-gray-600 dark:text-gray-400 mb-4">{selectedGame.pack_name}</p>
<div className="grid grid-cols-2 gap-3 sm:gap-4 mb-6 text-sm sm:text-base">
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Players:</span>
<span className="ml-2 text-gray-600 dark:text-gray-400">
{selectedGame.min_players}-{selectedGame.max_players}
</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Length:</span>
<span className="ml-2 text-gray-600 dark:text-gray-400">
{selectedGame.length_minutes ? `${selectedGame.length_minutes} min` : 'Unknown'}
</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Type:</span>
<span className="ml-2 text-gray-600 dark:text-gray-400">
{selectedGame.game_type || 'N/A'}
</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Family Friendly:</span>
<span className="ml-2 text-gray-600 dark:text-gray-400">
{selectedGame.family_friendly ? 'Yes' : 'No'}
</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Play Count:</span>
<span className="ml-2 text-gray-600 dark:text-gray-400">{selectedGame.play_count}</span>
</div>
<div className="flex items-center gap-2">
<span
className="font-semibold text-gray-700 dark:text-gray-300"
title="Cumulative popularity across all sessions"
>
Popularity:
</span>
<PopularityBadge
upvotes={selectedGame.upvotes || 0}
downvotes={selectedGame.downvotes || 0}
popularityScore={selectedGame.popularity_score || 0}
size="md"
showCounts={true}
showNet={true}
showRatio={true}
/>
</div>
</div>
<div className="flex gap-4">
<button
onClick={handleAcceptGame}
className="flex-1 bg-green-600 dark:bg-green-700 text-white py-3 rounded-lg hover:bg-green-700 dark:hover:bg-green-800 transition font-semibold"
>
✓ Play This Game
</button>
<button
onClick={handlePickGame}
className="flex-1 bg-yellow-600 dark:bg-yellow-700 text-white py-3 rounded-lg hover:bg-yellow-700 dark:hover:bg-yellow-800 transition font-semibold"
>
🎲 Re-roll
</button>
<button
onClick={handleDismissGame}
className="bg-gray-500 dark:bg-gray-600 text-white px-4 py-3 rounded-lg hover:bg-gray-600 dark:hover:bg-gray-700 transition font-semibold"
title="Cancel"
>
Cancel
</button>
</div>
{/* Other Versions Suggestion */}
{similarVersions.length > 0 && (
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold mb-3 text-gray-800 dark:text-gray-100">
🔄 Other Versions Available
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
This game has multiple versions. You can choose a different one:
</p>
<div className="space-y-2">
{similarVersions.map((version) => (
<button
key={version.id}
onClick={() => handleSelectVersion(version.id)}
className="w-full text-left p-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 transition group"
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="font-semibold text-gray-800 dark:text-gray-100 group-hover:text-indigo-600 dark:group-hover:text-indigo-400">
{version.title}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{version.pack_name}
</div>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 ml-2">
{version.min_players}-{version.max_players} players
</div>
</div>
</button>
))}
</div>
</div>
)}
</div>
)}
{showManualSelect && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-6 mb-6">
<h3 className="text-lg sm:text-xl font-semibold mb-4 text-gray-800 dark:text-gray-100">
Manual Game Selection
</h3>
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
<div className="flex-1 relative manual-search-container">
<input
type="text"
value={manualSearchQuery}
onChange={handleManualSearchChange}
onFocus={() => {
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 && (
<div className="absolute z-50 w-full bottom-full mb-1 sm:bottom-auto sm:top-full sm:mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{filteredManualGames.map((game) => (
<button
key={game.id}
onClick={() => handleSelectManualGame(game)}
className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors border-b border-gray-200 dark:border-gray-600 last:border-b-0"
>
<div className="font-semibold text-gray-800 dark:text-gray-100">
{game.title}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-0.5">
{game.pack_name} • {game.min_players}-{game.max_players} players
</div>
</button>
))}
</div>
)}
{/* No results message - above on mobile, below on desktop */}
{manualSearchQuery.trim() && filteredManualGames.length === 0 && !showManualDropdown && (
<div className="absolute z-50 w-full bottom-full mb-1 sm:bottom-auto sm:top-full sm:mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg px-4 py-3 text-gray-600 dark:text-gray-400 text-sm">
No games found matching "{manualSearchQuery}"
</div>
)}
</div>
<button
onClick={handleAddManualGame}
disabled={!manualGameId}
className="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition disabled:bg-gray-400 dark:disabled:bg-gray-600"
>
Add
</button>
</div>
</div>
)}
{/* Session info and games */}
<SessionInfo
sessionId={activeSession.id}
onGamesUpdate={gamesUpdateTrigger}
playingGame={playingGame}
setPlayingGame={setPlayingGame}
setHasPlayedGames={setHasPlayedGames}
setLeadingGame={setLeadingGame}
setPollActive={setPollActive}
pollActiveRef={pollActiveRef}
setPollResult={setPollResult}
pollStartedAtRef={pollStartedAtRef}
setSelectedGame={setSelectedGame}
setGameSource={setGameSource}
/>
</div>
</div>
</div>
);
}
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 (
<span className="inline-flex items-center gap-1 text-xs bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded font-semibold">
🎮 Playing
</span>
);
}
if (status === 'skipped') {
return (
<span className="inline-flex items-center gap-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 px-2 py-1 rounded">
Skipped
</span>
);
}
return null;
};
return (
<>
{/* Room Code Modal for Repeat Game */}
<RoomCodeModal
isOpen={showRepeatRoomCodeModal}
onConfirm={handleRepeatRoomCodeConfirm}
onCancel={handleRepeatRoomCodeCancel}
gameName={repeatGameData?.title}
/>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg sm:text-xl font-semibold text-gray-800 dark:text-gray-100">
Games Played This Session ({games.length})
</h3>
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
<input
type="checkbox"
checked={showPopularity}
onChange={(e) => setShowPopularity(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700 cursor-pointer"
/>
<span className="whitespace-nowrap">Show Popularity</span>
</label>
</div>
{loading ? (
<p className="text-gray-500 dark:text-gray-400">Loading...</p>
) : games.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400">No games played yet. Pick a game to get started!</p>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{games.map((game, index) => {
const displayNumber = games.length - index;
return (
<div
key={game.id}
className={`p-3 rounded border transition ${
game.status === 'playing'
? 'bg-green-50 dark:bg-green-900/20 border-green-300 dark:border-green-700'
: game.status === 'skipped'
? 'bg-gray-100 dark:bg-gray-700/50 border-gray-300 dark:border-gray-600'
: 'bg-gray-50 dark:bg-gray-700/30 border-gray-200 dark:border-gray-600'
}`}
>
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={`font-semibold text-sm sm:text-base ${
game.status === 'skipped'
? 'text-gray-500 dark:text-gray-500 line-through'
: 'text-gray-700 dark:text-gray-200'
}`}>
{displayNumber}. {game.title}
</span>
{getStatusBadge(game.status)}
{game.source === 'manual' || (game.manually_added === 1 && game.source !== 'poll') ? (
<span className="text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded">
Manual
</span>
) : null}
{game.source === 'poll' && (
<span className="text-xs bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200 px-2 py-1 rounded">
Poll
</span>
)}
{game.room_code && (
<div className="flex items-center gap-1 flex-wrap">
{editingRoomCode === game.id ? (
<div className="flex items-center gap-1">
<input
type="text"
value={newRoomCode}
onChange={handleRoomCodeChange}
className="w-16 px-2 py-1 text-xs font-mono font-bold text-center border border-indigo-400 dark:border-indigo-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 uppercase focus:outline-none focus:ring-1 focus:ring-indigo-500"
maxLength={4}
autoFocus
/>
<button
onClick={() => handleSaveRoomCode(game.id)}
disabled={newRoomCode.length !== 4}
className="text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<button
onClick={handleCancelEditRoomCode}
className="text-xs px-2 py-1 bg-gray-500 text-white rounded hover:bg-gray-600"
>
</button>
</div>
) : (
<>
<span className="inline-flex items-center gap-1 text-xs bg-indigo-600 dark:bg-indigo-700 text-white px-2 py-1 rounded font-mono font-bold">
🎮 {game.room_code}
</span>
{isAuthenticated && (
<button
onClick={() => handleEditRoomCode(game.id, game.room_code)}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400"
title="Edit room code"
>
</button>
)}
</>
)}
{/* Player Count Display */}
{game.player_count_check_status && game.player_count_check_status !== 'not_started' && (
<div className="flex items-center gap-1">
{game.player_count_check_status === 'monitoring' && !game.player_count && (
<span className="inline-flex items-center gap-1 text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded">
📡 Monitoring...
</span>
)}
{(game.player_count_check_status === 'checking' || (game.player_count_check_status === 'monitoring' && game.player_count)) && (
<span className="inline-flex items-center gap-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded">
📡 {game.player_count ? `${game.player_count} players` : 'Monitoring...'}
</span>
)}
{game.player_count_check_status === 'completed' && game.player_count && (
<>
{editingPlayerCount === game.id ? (
<div className="flex items-center gap-1">
<input
type="number"
value={newPlayerCount}
onChange={(e) => 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
/>
<button
onClick={() => handleSavePlayerCount(game.id)}
disabled={!newPlayerCount || parseInt(newPlayerCount) < 0}
className="text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<button
onClick={handleCancelEditPlayerCount}
className="text-xs px-2 py-1 bg-gray-500 text-white rounded hover:bg-gray-600"
>
</button>
</div>
) : (
<>
<span className="inline-flex items-center gap-1 text-xs bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded font-semibold">
{game.player_count} players
</span>
{isAuthenticated && (
<button
onClick={() => handleEditPlayerCount(game.id, game.player_count)}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-green-600 dark:hover:text-green-400"
title="Edit player count"
>
</button>
)}
</>
)}
</>
)}
{game.player_count_check_status === 'failed' && (
<>
{editingPlayerCount === game.id ? (
<div className="flex items-center gap-1">
<input
type="number"
value={newPlayerCount}
onChange={(e) => 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
/>
<button
onClick={() => handleSavePlayerCount(game.id)}
disabled={!newPlayerCount || parseInt(newPlayerCount) < 0}
className="text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<button
onClick={handleCancelEditPlayerCount}
className="text-xs px-2 py-1 bg-gray-500 text-white rounded hover:bg-gray-600"
>
</button>
</div>
) : (
<>
<button
onClick={() => handleRetryPlayerCount(game.id, game.room_code)}
className="inline-flex items-center gap-1 text-xs bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200 px-2 py-1 rounded hover:bg-orange-200 dark:hover:bg-orange-800 transition cursor-pointer"
title="Click to retry detection"
>
Unknown
</button>
{isAuthenticated && (
<button
onClick={() => handleEditPlayerCount(game.id, null)}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-orange-600 dark:hover:text-orange-400"
title="Set player count manually"
>
</button>
)}
</>
)}
</>
)}
{/* Stop button for active checks */}
{isAuthenticated && (game.player_count_check_status === 'monitoring' || game.player_count_check_status === 'checking') && (
<button
onClick={() => handleStopPlayerCountCheck(game.id)}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400"
title="Stop checking player count"
>
</button>
)}
</div>
)}
</div>
)}
{showPopularity && (
<PopularityBadge
upvotes={game.upvotes || 0}
downvotes={game.downvotes || 0}
popularityScore={game.popularity_score || 0}
size="sm"
showCounts={true}
showNet={true}
showRatio={false}
/>
)}
</div>
<div className="text-xs sm:text-sm text-gray-500 dark:text-gray-400 mt-1">
{game.pack_name} {formatLocalTime(game.played_at)}
</div>
</div>
</div>
{/* Action buttons for admins */}
{isAuthenticated && (
<div className="flex flex-wrap gap-2">
<button
onClick={() => handleRepeatGame(game)}
className="text-xs px-3 py-1 bg-purple-600 dark:bg-purple-700 text-white rounded hover:bg-purple-700 dark:hover:bg-purple-800 transition"
title="Play this game again"
>
🔁 Repeat
</button>
{game.status !== 'playing' && (
<button
onClick={() => handleUpdateStatus(game.id, 'playing')}
className="text-xs px-3 py-1 bg-green-600 dark:bg-green-700 text-white rounded hover:bg-green-700 dark:hover:bg-green-800 transition"
>
Mark as Playing
</button>
)}
{game.status === 'playing' && (
<button
onClick={() => handleUpdateStatus(game.id, 'played')}
className="text-xs px-3 py-1 bg-blue-600 dark:bg-blue-700 text-white rounded hover:bg-blue-700 dark:hover:bg-blue-800 transition"
>
Mark as Played
</button>
)}
{game.status !== 'skipped' && (
<button
onClick={() => handleUpdateStatus(game.id, 'skipped')}
className="text-xs px-3 py-1 bg-gray-600 dark:bg-gray-700 text-white rounded hover:bg-gray-700 dark:hover:bg-gray-800 transition"
>
Mark as Skipped
</button>
)}
<button
onClick={() => handleRemoveClick(game.id)}
className={`text-xs px-3 py-1 rounded transition ${
confirmingRemove === game.id
? 'bg-red-700 dark:bg-red-800 text-white animate-pulse'
: 'bg-red-600 dark:bg-red-700 text-white hover:bg-red-700 dark:hover:bg-red-800'
}`}
>
{confirmingRemove === game.id ? 'Confirm?' : 'Remove'}
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</>
);
}
export default Picker;