Files
jackboxpartypack-gamepicker/frontend/src/pages/Picker.jsx

1512 lines
64 KiB
React
Raw Normal View History

2025-10-30 17:52:44 -04:00
import React, { useState, useEffect, useCallback } from 'react';
2025-10-30 04:27:43 -04:00
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../api/axios';
import GamePoolModal from '../components/GamePoolModal';
2025-11-02 16:06:31 -05:00
import RoomCodeModal from '../components/RoomCodeModal';
import { formatLocalTime } from '../utils/dateUtils';
2025-10-30 17:18:30 -04:00
import PopularityBadge from '../components/PopularityBadge';
2025-10-30 04:27:43 -04:00
function Picker() {
const { isAuthenticated, loading: authLoading, token } = useAuth();
2025-10-30 04:27:43 -04:00
const navigate = useNavigate();
const [activeSession, setActiveSession] = useState(null);
const [allGames, setAllGames] = useState([]);
const [selectedGame, setSelectedGame] = useState(null);
2025-11-03 17:56:15 -05:00
const [playingGame, setPlayingGame] = useState(null); // Currently playing game
2025-10-30 04:27:43 -04:00
const [loading, setLoading] = useState(true);
const [picking, setPicking] = useState(false);
const [error, setError] = useState('');
2025-10-30 17:18:30 -04:00
const [showPopularity, setShowPopularity] = useState(true);
2025-10-30 17:34:44 -04:00
const [sessionEnded, setSessionEnded] = useState(false);
2025-10-30 04:27:43 -04:00
// 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('');
2025-11-03 17:56:15 -05:00
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);
2025-11-02 16:06:31 -05:00
// Room code modal
const [showRoomCodeModal, setShowRoomCodeModal] = useState(false);
const [pendingGameAction, setPendingGameAction] = useState(null);
2025-10-30 04:27:43 -04:00
2025-10-30 17:52:44 -04:00
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);
2025-10-30 04:27:43 -04:00
}
2025-10-30 17:52:44 -04:00
}, []);
2025-10-30 04:27:43 -04:00
2025-10-30 17:52:44 -04:00
const loadData = useCallback(async () => {
2025-10-30 04:27:43 -04:00
try {
2025-10-30 17:34:44 -04:00
// 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;
2025-10-30 17:34:44 -04:00
// Don't auto-create session - let user create it explicitly
setActiveSession(session);
2025-10-30 04:27:43 -04:00
2025-11-03 18:38:27 -05:00
// Load all enabled games for manual selection
const gamesResponse = await api.get('/games?enabled=true');
2025-10-30 04:27:43 -04:00
setAllGames(gamesResponse.data);
2025-11-03 17:56:15 -05:00
// Load currently playing game if session exists
if (session && session.id) {
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);
}
} catch (err) {
console.error('Failed to load playing game', err);
}
}
2025-10-30 04:27:43 -04:00
} catch (err) {
setError('Failed to load session data');
} finally {
setLoading(false);
}
2025-10-30 17:52:44 -04:00
}, []);
2025-10-30 04:27:43 -04:00
2025-10-30 17:52:44 -04:00
useEffect(() => {
// Wait for auth to finish loading before checking authentication
if (authLoading) return;
if (!isAuthenticated) {
navigate('/login');
return;
2025-10-30 17:34:44 -04:00
}
2025-10-30 17:52:44 -04:00
loadData();
}, [isAuthenticated, authLoading, navigate, loadData]);
// Fallback poll for session status — WebSocket events handle most updates
2025-10-30 17:52:44 -04:00
useEffect(() => {
if (!isAuthenticated || authLoading) return;
const interval = setInterval(() => {
checkActiveSession();
}, 60000);
2025-10-30 17:52:44 -04:00
return () => clearInterval(interval);
}, [isAuthenticated, authLoading, checkActiveSession]);
2025-10-30 17:34:44 -04:00
2025-11-03 17:56:15 -05:00
// 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]);
2025-10-30 17:34:44 -04:00
const handleCreateSession = async () => {
try {
const newSession = await api.post('/sessions', {});
setActiveSession(newSession.data);
setSessionEnded(false);
setError('');
} catch (err) {
setError('Failed to create session');
}
};
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);
}
};
2025-10-30 04:27:43 -04:00
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
2025-10-30 04:27:43 -04:00
});
setSelectedGame(response.data.game);
} catch (err) {
setError(err.response?.data?.error || 'Failed to pick a game');
setSelectedGame(null);
} finally {
setPicking(false);
}
};
const handleAcceptGame = async () => {
if (!selectedGame || !activeSession) return;
2025-11-02 16:06:31 -05:00
// Show room code modal
setPendingGameAction({
type: 'accept',
game: selectedGame
});
setShowRoomCodeModal(true);
};
const handleRoomCodeConfirm = async (roomCode) => {
if (!pendingGameAction || !activeSession) return;
2025-10-30 04:27:43 -04:00
try {
2025-11-02 16:06:31 -05:00
const { type, game, gameId } = pendingGameAction;
if (type === 'accept' || type === 'version') {
2025-11-03 17:56:15 -05:00
const response = await api.post(`/sessions/${activeSession.id}/games`, {
2025-11-02 16:06:31 -05:00
game_id: gameId || game.id,
manually_added: false,
room_code: roomCode
});
2025-11-03 17:56:15 -05:00
// Set the newly added game as playing
setPlayingGame(response.data);
2025-11-02 16:06:31 -05:00
} else if (type === 'manual') {
2025-11-03 17:56:15 -05:00
const response = await api.post(`/sessions/${activeSession.id}/games`, {
2025-11-02 16:06:31 -05:00
game_id: gameId,
manually_added: true,
room_code: roomCode
});
setManualGameId('');
setShowManualSelect(false);
2025-11-03 17:56:15 -05:00
// Set the newly added game as playing
setPlayingGame(response.data);
2025-11-02 16:06:31 -05:00
}
2025-10-30 04:27:43 -04:00
2025-11-03 17:56:15 -05:00
// Close all modals and clear selected game after adding to session
setSelectedGame(null);
setShowGamePool(false);
// Trigger games list refresh
setGamesUpdateTrigger(prev => prev + 1);
2025-10-30 04:27:43 -04:00
setError('');
} catch (err) {
setError('Failed to add game to session');
2025-11-02 16:06:31 -05:00
} finally {
setShowRoomCodeModal(false);
setPendingGameAction(null);
2025-10-30 04:27:43 -04:00
}
};
2025-11-02 16:06:31 -05:00
const handleRoomCodeCancel = () => {
setShowRoomCodeModal(false);
setPendingGameAction(null);
};
2025-10-30 04:27:43 -04:00
const handleAddManualGame = async () => {
if (!manualGameId || !activeSession) return;
2025-11-02 16:06:31 -05:00
// Show room code modal
const game = allGames.find(g => g.id === parseInt(manualGameId));
setPendingGameAction({
type: 'manual',
gameId: parseInt(manualGameId),
game: game
});
setShowRoomCodeModal(true);
2025-11-03 17:56:15 -05:00
// Reset search
setManualSearchQuery('');
setShowManualDropdown(false);
setManualGameId('');
2025-10-30 04:27:43 -04:00
};
2025-11-03 17:56:15 -05:00
// 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;
2025-11-02 16:06:31 -05:00
// 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) {
2025-10-30 04:27:43 -04:00
return (
<div className="flex justify-center items-center h-64">
<div className="text-xl text-gray-600 dark:text-gray-400">Loading...</div>
2025-10-30 04:27:43 -04:00
</div>
);
}
if (!activeSession) {
return (
<div className="max-w-4xl mx-auto">
2025-10-30 17:34:44 -04:00
<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>
</>
)}
2025-10-30 04:27:43 -04:00
</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">
2025-10-30 04:27:43 -04:00
<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"
2025-10-30 04:27:43 -04:00
/>
</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>
2025-10-30 04:27:43 -04:00
{/* Filter content - collapsible on mobile */}
<div className={`${showFilters ? 'block' : 'hidden'} md:block p-4 space-y-3`}>
2025-10-30 04:27:43 -04:00
<div>
<label className="block text-sm text-gray-700 dark:text-gray-300 font-semibold mb-1">
Player Count
2025-10-30 04:27:43 -04:00
</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>
2025-10-30 04:27:43 -04:00
</div>
<div>
<label className="block text-sm text-gray-700 dark:text-gray-300 font-semibold mb-1">
2025-10-30 04:27:43 -04:00
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"
2025-10-30 04:27:43 -04:00
>
<option value="">Any</option>
<option value="short">Short &lt;15 min</option>
2025-10-30 04:27:43 -04:00
<option value="medium">Medium (16-25 min)</option>
<option value="long">Long &gt;25 min</option>
2025-10-30 04:27:43 -04:00
</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>
2025-10-30 04:27:43 -04:00
<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>
2025-10-30 04:27:43 -04:00
</div>
</div>
</div>
{/* Game Pool Modal */}
{showGamePool && (
<GamePoolModal
games={eligibleGames}
onClose={() => setShowGamePool(false)}
/>
)}
2025-10-30 04:27:43 -04:00
2025-11-02 16:06:31 -05:00
{/* Room Code Modal */}
<RoomCodeModal
isOpen={showRoomCodeModal}
onConfirm={handleRoomCodeConfirm}
onCancel={handleRoomCodeCancel}
gameName={pendingGameAction?.game?.title}
/>
2025-10-30 04:27:43 -04:00
{/* 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">
2025-10-30 04:27:43 -04:00
{error}
</div>
)}
2025-11-03 17:56:15 -05:00
{/* 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">
2025-11-03 17:56:15 -05:00
{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>
)}
{/* 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={() => setSelectedGame(null)}
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">
2025-10-30 04:27:43 -04:00
{selectedGame.title}
</h2>
<p className="text-lg sm:text-xl text-gray-600 dark:text-gray-400 mb-4">{selectedGame.pack_name}</p>
2025-10-30 04:27:43 -04:00
<div className="grid grid-cols-2 gap-3 sm:gap-4 mb-6 text-sm sm:text-base">
2025-10-30 04:27:43 -04:00
<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">
2025-10-30 04:27:43 -04:00
{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">
2025-10-30 04:27:43 -04:00
{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">
2025-10-30 04:27:43 -04:00
{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">
2025-10-30 04:27:43 -04:00
{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>
2025-10-30 04:27:43 -04:00
</div>
2025-10-30 17:18:30 -04:00
<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:
2025-10-30 04:27:43 -04:00
</span>
2025-10-30 17:18:30 -04:00
<PopularityBadge
upvotes={selectedGame.upvotes || 0}
downvotes={selectedGame.downvotes || 0}
popularityScore={selectedGame.popularity_score || 0}
size="md"
showCounts={true}
showNet={true}
showRatio={true}
/>
2025-10-30 04:27:43 -04:00
</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"
2025-10-30 04:27:43 -04:00
>
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"
2025-10-30 04:27:43 -04:00
>
🎲 Re-roll
</button>
2025-11-03 17:56:15 -05:00
<button
onClick={() => setSelectedGame(null)}
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>
2025-10-30 04:27:43 -04:00
</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>
)}
2025-10-30 04:27:43 -04:00
</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">
2025-10-30 04:27:43 -04:00
Manual Game Selection
</h3>
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
2025-11-03 17:56:15 -05:00
<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>
2025-10-30 04:27:43 -04:00
<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"
2025-10-30 04:27:43 -04:00
>
Add
</button>
</div>
</div>
)}
{/* Session info and games */}
2025-11-03 17:56:15 -05:00
<SessionInfo
sessionId={activeSession.id}
onGamesUpdate={gamesUpdateTrigger}
playingGame={playingGame}
setPlayingGame={setPlayingGame}
/>
2025-10-30 04:27:43 -04:00
</div>
</div>
</div>
);
}
2025-11-03 17:56:15 -05:00
function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame }) {
const { isAuthenticated, token } = useAuth();
2025-10-30 04:27:43 -04:00
const [games, setGames] = useState([]);
const [loading, setLoading] = useState(true);
const [confirmingRemove, setConfirmingRemove] = useState(null);
2025-10-30 17:18:30 -04:00
const [showPopularity, setShowPopularity] = useState(true);
2025-11-02 16:06:31 -05:00
const [editingRoomCode, setEditingRoomCode] = useState(null);
const [newRoomCode, setNewRoomCode] = useState('');
2025-11-03 17:56:15 -05:00
const [showRepeatRoomCodeModal, setShowRepeatRoomCodeModal] = useState(false);
const [repeatGameData, setRepeatGameData] = useState(null);
const [wsConnection, setWsConnection] = useState(null);
const [editingPlayerCount, setEditingPlayerCount] = useState(null);
const [newPlayerCount, setNewPlayerCount] = useState('');
2025-10-30 04:27:43 -04:00
2025-10-30 17:52:44 -04:00
const loadGames = useCallback(async () => {
2025-10-30 04:27:43 -04:00
try {
const response = await api.get(`/sessions/${sessionId}/games`);
// Reverse chronological order (most recent first)
setGames(response.data.reverse());
2025-10-30 04:27:43 -04:00
} catch (err) {
console.error('Failed to load session games');
} finally {
setLoading(false);
}
2025-10-30 17:52:44 -04:00
}, [sessionId]);
useEffect(() => {
loadGames();
}, [sessionId, onGamesUpdate, loadGames]);
// Fallback polling — WebSocket events handle most updates; this is a safety net
2025-10-30 17:52:44 -04:00
useEffect(() => {
const interval = setInterval(() => {
loadGames();
}, 60000);
2025-10-30 17:52:44 -04:00
return () => clearInterval(interval);
}, [loadGames]);
2025-10-30 04:27:43 -04:00
// Setup WebSocket connection for real-time session updates
2025-11-03 17:56:15 -05:00
useEffect(() => {
if (!token) return;
2025-11-03 17:56:15 -05:00
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);
ws.onopen = () => {
console.log('[WebSocket] Connected, authenticating...');
ws.send(JSON.stringify({ type: 'auth', token }));
2025-11-03 17:56:15 -05:00
};
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) }));
return;
}
const reloadEvents = [
'room.connected',
'lobby.player-joined',
'lobby.updated',
'game.started',
'game.ended',
'room.disconnected',
'player-count.updated',
'game.added',
'game.status',
];
if (reloadEvents.includes(message.type)) {
console.log(`[WebSocket] ${message.type}:`, message.data);
2025-11-03 17:56:15 -05:00
loadGames();
}
} catch (error) {
console.error('[WebSocket] Error parsing message:', error);
}
};
ws.onerror = (error) => {
console.error('[WebSocket] Error:', error);
};
ws.onclose = () => {
console.log('[WebSocket] Disconnected');
};
setWsConnection(ws);
return () => {
if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
};
} catch (error) {
console.error('[WebSocket] Failed to connect:', error);
}
}, [sessionId, token, loadGames]);
2025-11-03 17:56:15 -05:00
const handleUpdateStatus = async (gameId, newStatus) => {
try {
await api.patch(`/sessions/${sessionId}/games/${gameId}/status`, { status: newStatus });
2025-11-03 17:56:15 -05:00
// 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}`);
2025-11-03 17:56:15 -05:00
// 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);
}
};
2025-11-02 16:06:31 -05:00
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('');
};
2025-11-03 17:56:15 -05:00
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;
};
2025-10-30 04:27:43 -04:00
return (
2025-11-03 17:56:15 -05:00
<>
{/* 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>
2025-10-30 04:27:43 -04:00
{loading ? (
<p className="text-gray-500 dark:text-gray-400">Loading...</p>
2025-10-30 04:27:43 -04:00
) : games.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400">No games played yet. Pick a game to get started!</p>
2025-10-30 04:27:43 -04:00
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
2025-10-30 17:52:44 -04:00
{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'
}`}>
2025-10-30 17:52:44 -04:00
{displayNumber}. {game.title}
2025-11-03 17:56:15 -05:00
</span>
{getStatusBadge(game.status)}
{game.manually_added === 1 && (
<span className="text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded">
Manual
2025-11-03 17:56:15 -05:00
</span>
)}
2025-11-02 16:06:31 -05:00
{game.room_code && (
2025-11-03 17:56:15 -05:00
<div className="flex items-center gap-1 flex-wrap">
2025-11-02 16:06:31 -05:00
{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"
>
2025-11-03 17:56:15 -05:00
</button>
2025-11-02 16:06:31 -05:00
<button
onClick={handleCancelEditRoomCode}
className="text-xs px-2 py-1 bg-gray-500 text-white rounded hover:bg-gray-600"
>
2025-11-03 17:56:15 -05:00
</button>
2025-11-02 16:06:31 -05:00
</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"
>
2025-11-03 17:56:15 -05:00
</button>
2025-11-02 16:06:31 -05:00
)}
</>
)}
2025-11-03 17:56:15 -05:00
{/* 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 && (
2025-11-03 17:56:15 -05:00
<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...
2025-11-03 17:56:15 -05:00
</span>
)}
{(game.player_count_check_status === 'checking' || (game.player_count_check_status === 'monitoring' && game.player_count)) && (
2025-11-03 17:56:15 -05:00
<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...'}
2025-11-03 17:56:15 -05:00
</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') && (
2025-11-03 17:56:15 -05:00
<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>
)}
2025-11-02 16:06:31 -05:00
</div>
)}
2025-10-30 17:18:30 -04:00
{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>
2025-10-30 04:27:43 -04:00
</div>
{/* Action buttons for admins */}
{isAuthenticated && (
<div className="flex flex-wrap gap-2">
2025-11-03 17:56:15 -05:00
<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>
)}
2025-10-30 04:27:43 -04:00
</div>
);
})}
2025-10-30 04:27:43 -04:00
</div>
)}
2025-11-03 17:56:15 -05:00
</div>
</>
2025-10-30 04:27:43 -04:00
);
}
export default Picker;