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

812 lines
34 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 } 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 &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)}
/>
)}
{/* 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;