812 lines
34 KiB
JavaScript
812 lines
34 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { useAuth } from '../context/AuthContext';
|
||
import api from '../api/axios';
|
||
import GamePoolModal from '../components/GamePoolModal';
|
||
import { formatLocalTime } from '../utils/dateUtils';
|
||
|
||
function Picker() {
|
||
const { isAuthenticated, loading: authLoading } = useAuth();
|
||
const navigate = useNavigate();
|
||
|
||
const [activeSession, setActiveSession] = useState(null);
|
||
const [allGames, setAllGames] = useState([]);
|
||
const [selectedGame, setSelectedGame] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [picking, setPicking] = useState(false);
|
||
const [error, setError] = useState('');
|
||
|
||
// 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('');
|
||
|
||
// 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);
|
||
|
||
useEffect(() => {
|
||
// Wait for auth to finish loading before checking authentication
|
||
if (authLoading) return;
|
||
|
||
if (!isAuthenticated) {
|
||
navigate('/login');
|
||
return;
|
||
}
|
||
loadData();
|
||
}, [isAuthenticated, authLoading, navigate]);
|
||
|
||
const loadData = async () => {
|
||
try {
|
||
// Load active session or create one
|
||
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;
|
||
|
||
// If no active session, create one
|
||
if (!session || !session.id) {
|
||
const newSession = await api.post('/sessions', {});
|
||
session = newSession.data;
|
||
}
|
||
|
||
setActiveSession(session);
|
||
|
||
// Load all games for manual selection
|
||
const gamesResponse = await api.get('/games');
|
||
setAllGames(gamesResponse.data);
|
||
} catch (err) {
|
||
setError('Failed to load session data');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
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);
|
||
} catch (err) {
|
||
setError(err.response?.data?.error || 'Failed to pick a game');
|
||
setSelectedGame(null);
|
||
} finally {
|
||
setPicking(false);
|
||
}
|
||
};
|
||
|
||
const handleAcceptGame = async () => {
|
||
if (!selectedGame || !activeSession) return;
|
||
|
||
try {
|
||
await api.post(`/sessions/${activeSession.id}/games`, {
|
||
game_id: selectedGame.id,
|
||
manually_added: false
|
||
});
|
||
|
||
// Trigger games list refresh
|
||
setGamesUpdateTrigger(prev => prev + 1);
|
||
setSelectedGame(null);
|
||
setError('');
|
||
} catch (err) {
|
||
setError('Failed to add game to session');
|
||
}
|
||
};
|
||
|
||
const handleAddManualGame = async () => {
|
||
if (!manualGameId || !activeSession) return;
|
||
|
||
try {
|
||
await api.post(`/sessions/${activeSession.id}/games`, {
|
||
game_id: parseInt(manualGameId),
|
||
manually_added: true
|
||
});
|
||
|
||
// Trigger games list refresh
|
||
setGamesUpdateTrigger(prev => prev + 1);
|
||
setManualGameId('');
|
||
setShowManualSelect(false);
|
||
setError('');
|
||
} catch (err) {
|
||
setError('Failed to add game to session');
|
||
}
|
||
};
|
||
|
||
const handleSelectVersion = async (gameId) => {
|
||
if (!activeSession) return;
|
||
|
||
try {
|
||
await api.post(`/sessions/${activeSession.id}/games`, {
|
||
game_id: gameId,
|
||
manually_added: false
|
||
});
|
||
|
||
// Trigger games list refresh
|
||
setGamesUpdateTrigger(prev => prev + 1);
|
||
setSelectedGame(null);
|
||
setError('');
|
||
} catch (err) {
|
||
setError('Failed to add game to session');
|
||
}
|
||
};
|
||
|
||
// 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-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-200 p-4 rounded">
|
||
Failed to load or create session. Please try again.
|
||
</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 <15 min</option>
|
||
<option value="medium">Medium (16-25 min)</option>
|
||
<option value="long">Long >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)}
|
||
/>
|
||
)}
|
||
|
||
{/* 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>
|
||
)}
|
||
|
||
{selectedGame && (
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-8 mb-6">
|
||
<h2 className="text-2xl sm:text-3xl font-bold mb-4 text-gray-800 dark:text-gray-100">
|
||
{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>
|
||
<span className="font-semibold text-gray-700 dark:text-gray-300">Popularity:</span>
|
||
<span className="ml-2 text-gray-600 dark:text-gray-400">
|
||
{selectedGame.popularity_score > 0 ? '+' : ''}
|
||
{selectedGame.popularity_score}
|
||
</span>
|
||
</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>
|
||
</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">
|
||
<select
|
||
value={manualGameId}
|
||
onChange={(e) => setManualGameId(e.target.value)}
|
||
className="flex-1 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"
|
||
>
|
||
<option value="">Select a game...</option>
|
||
{allGames.map((game) => (
|
||
<option key={game.id} value={game.id}>
|
||
{game.title} ({game.pack_name})
|
||
</option>
|
||
))}
|
||
</select>
|
||
<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} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SessionInfo({ sessionId, onGamesUpdate }) {
|
||
const { isAuthenticated } = useAuth();
|
||
const [games, setGames] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [confirmingRemove, setConfirmingRemove] = useState(null);
|
||
|
||
useEffect(() => {
|
||
loadGames();
|
||
}, [sessionId, onGamesUpdate]);
|
||
|
||
const loadGames = async () => {
|
||
try {
|
||
const response = await api.get(`/sessions/${sessionId}/games`);
|
||
// Reverse chronological order (most recent first)
|
||
setGames(response.data.reverse());
|
||
} catch (err) {
|
||
console.error('Failed to load session games');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleUpdateStatus = async (gameId, newStatus) => {
|
||
try {
|
||
await api.patch(`/sessions/${sessionId}/games/${gameId}/status`, { status: newStatus });
|
||
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}`);
|
||
setConfirmingRemove(null);
|
||
loadGames(); // Reload after deletion
|
||
} catch (err) {
|
||
console.error('Failed to remove game', err);
|
||
setConfirmingRemove(null);
|
||
}
|
||
};
|
||
|
||
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 (
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-6">
|
||
<h3 className="text-lg sm:text-xl font-semibold mb-4 text-gray-800 dark:text-gray-100">
|
||
Games Played This Session ({games.length})
|
||
</h3>
|
||
{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) => {
|
||
const index = games.length - games.indexOf(game);
|
||
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'
|
||
}`}>
|
||
{index + 1}. {game.title}
|
||
</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
|
||
</span>
|
||
)}
|
||
</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">
|
||
{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;
|
||
|