2026-03-20 23:34:22 -04:00
|
|
|
|
import React, { useState, useEffect, useCallback, useRef } 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';
|
2025-10-30 13:27:55 -04:00
|
|
|
|
import GamePoolModal from '../components/GamePoolModal';
|
2025-11-02 16:06:31 -05:00
|
|
|
|
import RoomCodeModal from '../components/RoomCodeModal';
|
2025-10-30 13:27:55 -04:00
|
|
|
|
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() {
|
2026-03-20 11:47:19 -04:00
|
|
|
|
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);
|
2026-05-07 20:44:04 -04:00
|
|
|
|
const [playingGame, setPlayingGame] = useState(null);
|
|
|
|
|
|
const [hasPlayedGames, setHasPlayedGames] = useState(false);
|
|
|
|
|
|
const [leadingGame, setLeadingGame] = useState(null);
|
|
|
|
|
|
const [pollActive, setPollActive] = useState(false);
|
|
|
|
|
|
const [pollResult, setPollResult] = useState(null);
|
|
|
|
|
|
const [pollElapsed, setPollElapsed] = useState(0);
|
|
|
|
|
|
const pollTimerRef = useRef(null);
|
|
|
|
|
|
const pollStartedAtRef = useRef(null);
|
2026-05-10 20:33:00 -04:00
|
|
|
|
const [pollEndingAt, setPollEndingAt] = useState(null);
|
|
|
|
|
|
const [pollCountdown, setPollCountdown] = useState(null);
|
|
|
|
|
|
const pollCountdownRef = useRef(null);
|
|
|
|
|
|
const [showEndPollOptions, setShowEndPollOptions] = useState(false);
|
|
|
|
|
|
const [customDelay, setCustomDelay] = useState('');
|
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([]);
|
2025-10-30 13:27:55 -04:00
|
|
|
|
|
|
|
|
|
|
// 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
|
2025-10-30 13:27:55 -04:00
|
|
|
|
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
|
2025-10-30 13:27:55 -04:00
|
|
|
|
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
|
|
|
|
|
2026-05-07 20:44:04 -04:00
|
|
|
|
// Load currently playing game and restore poll state if session exists
|
2025-11-03 17:56:15 -05:00
|
|
|
|
if (session && session.id) {
|
2026-05-07 20:44:04 -04:00
|
|
|
|
// Restore poll state from persisted session data
|
|
|
|
|
|
if (session.poll_active) {
|
|
|
|
|
|
pollStartedAtRef.current = session.poll_started_at || null;
|
|
|
|
|
|
setPollActive(true);
|
|
|
|
|
|
if (session.poll_leading_game_id) {
|
|
|
|
|
|
setLeadingGame({
|
|
|
|
|
|
gameId: session.poll_leading_game_id,
|
|
|
|
|
|
label: session.poll_leading_label,
|
|
|
|
|
|
votes: session.poll_leading_votes,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-05-10 20:33:00 -04:00
|
|
|
|
if (session.poll_ending_at && new Date(session.poll_ending_at) > new Date()) {
|
|
|
|
|
|
setPollEndingAt(session.poll_ending_at);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Restore pending game selection if another admin picked one
|
|
|
|
|
|
if (session.pending_game_id) {
|
|
|
|
|
|
const pendingGame = gamesResponse.data.find(g => g.id === session.pending_game_id);
|
|
|
|
|
|
if (pendingGame) {
|
|
|
|
|
|
setSelectedGame(pendingGame);
|
|
|
|
|
|
setGameSource(session.pending_game_source || 'dice');
|
|
|
|
|
|
}
|
2026-05-07 20:44:04 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-03 17:56:15 -05:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-05-07 20:44:04 -04:00
|
|
|
|
setHasPlayedGames(sessionGamesResponse.data.some(g => g.status === 'played'));
|
2025-11-03 17:56:15 -05:00
|
|
|
|
} 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]);
|
|
|
|
|
|
|
2026-03-20 21:05:19 -04:00
|
|
|
|
// 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();
|
2026-03-20 21:05:19 -04:00
|
|
|
|
}, 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]);
|
|
|
|
|
|
|
2026-05-07 20:44:04 -04:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (pollActive) {
|
|
|
|
|
|
const start = pollStartedAtRef.current
|
|
|
|
|
|
? new Date(pollStartedAtRef.current).getTime()
|
|
|
|
|
|
: Date.now();
|
|
|
|
|
|
pollTimerRef.current = setInterval(() => {
|
|
|
|
|
|
setPollElapsed(Date.now() - start);
|
|
|
|
|
|
}, 10);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
clearInterval(pollTimerRef.current);
|
|
|
|
|
|
setPollElapsed(0);
|
|
|
|
|
|
pollStartedAtRef.current = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
return () => clearInterval(pollTimerRef.current);
|
|
|
|
|
|
}, [pollActive]);
|
|
|
|
|
|
|
2026-05-10 20:33:00 -04:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (pollEndingAt) {
|
|
|
|
|
|
const tick = () => {
|
|
|
|
|
|
const remaining = Math.max(0, Math.ceil((new Date(pollEndingAt).getTime() - Date.now()) / 1000));
|
|
|
|
|
|
setPollCountdown(remaining);
|
|
|
|
|
|
if (remaining <= 0) {
|
|
|
|
|
|
clearInterval(pollCountdownRef.current);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
tick();
|
|
|
|
|
|
pollCountdownRef.current = setInterval(tick, 250);
|
|
|
|
|
|
return () => clearInterval(pollCountdownRef.current);
|
|
|
|
|
|
}
|
|
|
|
|
|
clearInterval(pollCountdownRef.current);
|
|
|
|
|
|
setPollCountdown(null);
|
|
|
|
|
|
}, [pollEndingAt]);
|
|
|
|
|
|
|
2026-05-07 20:44:04 -04:00
|
|
|
|
const formatElapsed = (ms) => {
|
|
|
|
|
|
const minutes = Math.floor(ms / 60000);
|
|
|
|
|
|
const seconds = Math.floor((ms % 60000) / 1000);
|
|
|
|
|
|
const centiseconds = Math.floor((ms % 1000) / 10);
|
|
|
|
|
|
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}:${String(centiseconds).padStart(2, '0')}`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-10 20:33:00 -04:00
|
|
|
|
const formatCountdown = (totalSeconds) => {
|
|
|
|
|
|
const m = Math.floor(totalSeconds / 60);
|
|
|
|
|
|
const s = totalSeconds % 60;
|
|
|
|
|
|
return `${m}:${String(s).padStart(2, '0')}`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-07 20:44:04 -04:00
|
|
|
|
const leadingGameRef = useRef(leadingGame);
|
|
|
|
|
|
leadingGameRef.current = leadingGame;
|
|
|
|
|
|
|
2026-05-10 20:33:00 -04:00
|
|
|
|
const pollActiveRef = useRef(pollActive);
|
|
|
|
|
|
pollActiveRef.current = pollActive;
|
|
|
|
|
|
|
2026-05-07 20:44:04 -04:00
|
|
|
|
const handleStartPolling = async () => {
|
2026-05-10 20:33:00 -04:00
|
|
|
|
pollStartedAtRef.current = new Date().toISOString();
|
|
|
|
|
|
setPollActive(true);
|
|
|
|
|
|
setPollResult(null);
|
2026-05-07 20:44:04 -04:00
|
|
|
|
try {
|
|
|
|
|
|
await api.post(`/sessions/${activeSession.id}/voting/start`);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Failed to start polling', err);
|
2026-05-10 20:33:00 -04:00
|
|
|
|
setPollActive(false);
|
|
|
|
|
|
pollStartedAtRef.current = null;
|
2026-05-07 20:44:04 -04:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-10 20:33:00 -04:00
|
|
|
|
const handleEndPolling = async (delay = 0) => {
|
|
|
|
|
|
setShowEndPollOptions(false);
|
|
|
|
|
|
setCustomDelay('');
|
|
|
|
|
|
|
|
|
|
|
|
if (delay === 0) {
|
|
|
|
|
|
const winner = leadingGameRef.current;
|
2026-05-07 20:44:04 -04:00
|
|
|
|
setPollActive(false);
|
2026-05-10 20:33:00 -04:00
|
|
|
|
setLeadingGame(null);
|
|
|
|
|
|
setPollEndingAt(null);
|
|
|
|
|
|
if (winner) {
|
|
|
|
|
|
setPollResult(winner);
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.post(`/sessions/${activeSession.id}/voting/end`, { delay: 0 });
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Failed to end polling', err);
|
|
|
|
|
|
setPollActive(true);
|
|
|
|
|
|
setLeadingGame(winner);
|
|
|
|
|
|
setPollResult(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await api.post(`/sessions/${activeSession.id}/voting/end`, { delay });
|
|
|
|
|
|
setPollEndingAt(res.data.endsAt);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Failed to schedule poll end', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleCancelPollEnd = async () => {
|
|
|
|
|
|
setPollEndingAt(null);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.post(`/sessions/${activeSession.id}/voting/cancel-end`);
|
2026-05-07 20:44:04 -04:00
|
|
|
|
} catch (err) {
|
2026-05-10 20:33:00 -04:00
|
|
|
|
console.error('Failed to cancel poll end', err);
|
2026-05-07 20:44:04 -04:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-10 20:33:00 -04:00
|
|
|
|
const [gameSource, setGameSource] = useState('dice');
|
|
|
|
|
|
|
2026-05-07 20:44:04 -04:00
|
|
|
|
const handleUsePollResult = () => {
|
|
|
|
|
|
if (pollResult) {
|
|
|
|
|
|
const game = allGames.find(g => g.id === pollResult.gameId);
|
|
|
|
|
|
if (game) {
|
|
|
|
|
|
setSelectedGame(game);
|
2026-05-10 20:33:00 -04:00
|
|
|
|
setGameSource('poll');
|
|
|
|
|
|
if (activeSession) {
|
|
|
|
|
|
api.post(`/sessions/${activeSession.id}/game-selection`, {
|
|
|
|
|
|
game_id: game.id,
|
|
|
|
|
|
source: 'poll'
|
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
|
}
|
2026-05-07 20:44:04 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
setPollResult(null);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleIgnorePollResult = () => {
|
|
|
|
|
|
setPollResult(null);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-10 20:33:00 -04:00
|
|
|
|
const handleDismissGame = () => {
|
|
|
|
|
|
setSelectedGame(null);
|
|
|
|
|
|
setGameSource('dice');
|
|
|
|
|
|
if (activeSession) {
|
|
|
|
|
|
api.delete(`/sessions/${activeSession.id}/game-selection`).catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-30 13:27:55 -04:00
|
|
|
|
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,
|
2025-10-30 13:27:55 -04:00
|
|
|
|
familyFriendly: familyFriendlyFilter ? familyFriendlyFilter === 'yes' : undefined,
|
|
|
|
|
|
excludePlayed: excludePlayedGames
|
2025-10-30 04:27:43 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
setSelectedGame(response.data.game);
|
2026-05-10 20:33:00 -04:00
|
|
|
|
setGameSource('dice');
|
|
|
|
|
|
api.post(`/sessions/${activeSession.id}/game-selection`, {
|
|
|
|
|
|
game_id: response.data.game.id,
|
|
|
|
|
|
source: 'dice'
|
|
|
|
|
|
}).catch(() => {});
|
2025-10-30 04:27:43 -04:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError(err.response?.data?.error || 'Failed to pick a game');
|
|
|
|
|
|
setSelectedGame(null);
|
2026-05-10 20:33:00 -04:00
|
|
|
|
setGameSource('dice');
|
2025-10-30 04:27:43 -04:00
|
|
|
|
} 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',
|
2026-05-10 20:33:00 -04:00
|
|
|
|
game: selectedGame,
|
|
|
|
|
|
source: gameSource
|
2025-11-02 16:06:31 -05:00
|
|
|
|
});
|
|
|
|
|
|
setShowRoomCodeModal(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleRoomCodeConfirm = async (roomCode) => {
|
|
|
|
|
|
if (!pendingGameAction || !activeSession) return;
|
|
|
|
|
|
|
2025-10-30 04:27:43 -04:00
|
|
|
|
try {
|
2026-05-10 20:33:00 -04:00
|
|
|
|
const { type, game, gameId, source } = pendingGameAction;
|
2025-11-02 16:06:31 -05:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-05-10 20:33:00 -04:00
|
|
|
|
room_code: roomCode,
|
|
|
|
|
|
source: source || 'dice'
|
2025-11-02 16:06:31 -05:00
|
|
|
|
});
|
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,
|
2026-05-10 20:33:00 -04:00
|
|
|
|
room_code: roomCode,
|
|
|
|
|
|
source: 'manual'
|
2025-11-02 16:06:31 -05:00
|
|
|
|
});
|
|
|
|
|
|
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);
|
2026-05-10 20:33:00 -04:00
|
|
|
|
setGameSource('dice');
|
2025-11-03 17:56:15 -05:00
|
|
|
|
setShowGamePool(false);
|
|
|
|
|
|
|
2025-10-30 13:27:55 -04:00
|
|
|
|
// 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);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2025-10-30 13:27:55 -04:00
|
|
|
|
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);
|
2025-10-30 13:27:55 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 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">
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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">
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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
|
2025-10-30 13:27:55 -04:00
|
|
|
|
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>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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
|
|
|
|
|
2025-10-30 13:27:55 -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>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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)}
|
2025-10-30 13:27:55 -04:00
|
|
|
|
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>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<option value="short">Short <15 min</option>
|
2025-10-30 04:27:43 -04:00
|
|
|
|
<option value="medium">Medium (16-25 min)</option>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<option value="long">Long >25 min</option>
|
2025-10-30 04:27:43 -04:00
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-10-30 13:27:55 -04:00
|
|
|
|
{/* 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
|
|
|
|
|
2025-10-30 13:27:55 -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>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
|
|
|
|
|
|
{/* 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 && (
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-07 20:44:04 -04:00
|
|
|
|
{/* Poll Leader Indicator */}
|
|
|
|
|
|
{leadingGame && (
|
|
|
|
|
|
<div className="bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800 rounded-lg p-3 sm:p-4 mb-4">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<span className="text-xs font-medium text-indigo-500 dark:text-indigo-400 uppercase tracking-wide">
|
|
|
|
|
|
Poll Leader
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="text-sm font-semibold text-indigo-800 dark:text-indigo-200">
|
|
|
|
|
|
{leadingGame.label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span className="text-xs text-indigo-500 dark:text-indigo-400">
|
|
|
|
|
|
{leadingGame.votes} votes
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
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>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-07 20:44:04 -04:00
|
|
|
|
{/* Poll Control Card */}
|
|
|
|
|
|
{pollResult ? (
|
|
|
|
|
|
<div className="bg-indigo-50 dark:bg-indigo-900/20 border-2 border-indigo-400 dark:border-indigo-700 rounded-lg shadow-lg p-4 sm:p-6 mb-6">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h3 className="text-lg font-semibold text-indigo-800 dark:text-indigo-200">
|
|
|
|
|
|
Poll Winner: {pollResult.label}
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<p className="text-sm text-indigo-600 dark:text-indigo-400 mt-1">
|
|
|
|
|
|
{pollResult.votes} votes — Use as the next game?
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={handleUsePollResult}
|
|
|
|
|
|
className="bg-green-600 dark:bg-green-700 text-white px-4 py-2 rounded-lg hover:bg-green-700 dark:hover:bg-green-800 transition font-semibold text-sm whitespace-nowrap"
|
|
|
|
|
|
>
|
|
|
|
|
|
Use as Choice
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={handleIgnorePollResult}
|
|
|
|
|
|
className="bg-gray-500 dark:bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-600 dark:hover:bg-gray-700 transition font-semibold text-sm whitespace-nowrap"
|
|
|
|
|
|
>
|
|
|
|
|
|
Ignore
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-10 20:33:00 -04:00
|
|
|
|
) : pollActive && pollEndingAt ? (
|
|
|
|
|
|
<div className="bg-red-50 dark:bg-red-900/20 border-2 border-red-400 dark:border-red-700 rounded-lg shadow-lg p-4 sm:p-6 mb-6">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h3 className="text-lg font-semibold text-red-800 dark:text-red-200">
|
|
|
|
|
|
Poll Ending
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
|
|
|
|
|
|
Voting will close automatically.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<div className="flex flex-col items-center">
|
|
|
|
|
|
<span className="text-sm text-red-700 dark:text-red-300 font-semibold">Ending in</span>
|
|
|
|
|
|
<span className="font-mono text-2xl tracking-wider text-red-800 dark:text-red-100 bg-red-200/60 dark:bg-red-800/40 px-4 py-2 rounded" style={{ fontFamily: "'Courier New', monospace" }}>
|
|
|
|
|
|
{pollCountdown !== null ? formatCountdown(pollCountdown) : '--:--'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={handleCancelPollEnd}
|
|
|
|
|
|
className="bg-gray-500 dark:bg-gray-600 text-white px-4 py-3 rounded-lg hover:bg-gray-600 dark:hover:bg-gray-700 transition font-semibold text-sm whitespace-nowrap"
|
|
|
|
|
|
>
|
|
|
|
|
|
Cancel
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-07 20:44:04 -04:00
|
|
|
|
) : pollActive ? (
|
|
|
|
|
|
<div className="bg-orange-50 dark:bg-orange-900/20 border-2 border-orange-400 dark:border-orange-700 rounded-lg shadow-lg p-4 sm:p-6 mb-6">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h3 className="text-lg font-semibold text-orange-800 dark:text-orange-200">
|
|
|
|
|
|
Voting In Progress
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<p className="text-sm text-orange-600 dark:text-orange-400 mt-1">
|
|
|
|
|
|
End the current poll when ready to pick the next game.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-05-10 20:33:00 -04:00
|
|
|
|
<div className="relative">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setShowEndPollOptions(prev => !prev)}
|
|
|
|
|
|
className="bg-orange-600 dark:bg-orange-700 text-white px-6 py-4 rounded-lg hover:bg-orange-700 dark:hover:bg-orange-800 transition font-semibold whitespace-nowrap flex flex-col items-center gap-1"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="text-sm">End Poll</span>
|
|
|
|
|
|
<span className="font-mono text-lg tracking-wider bg-black/20 px-3 py-1 rounded" style={{ fontFamily: "'Courier New', monospace" }}>
|
|
|
|
|
|
{formatElapsed(pollElapsed)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{showEndPollOptions && (
|
|
|
|
|
|
<div className="absolute right-0 top-full mt-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl z-50 w-56 py-1">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => handleEndPolling(0)}
|
|
|
|
|
|
className="w-full text-left px-4 py-2.5 text-sm font-semibold text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition"
|
|
|
|
|
|
>
|
|
|
|
|
|
End Now
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<hr className="border-gray-200 dark:border-gray-700 my-1" />
|
|
|
|
|
|
{[5, 10, 30].map(s => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={s}
|
|
|
|
|
|
onClick={() => handleEndPolling(s)}
|
|
|
|
|
|
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition"
|
|
|
|
|
|
>
|
|
|
|
|
|
{s} seconds
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
<hr className="border-gray-200 dark:border-gray-700 my-1" />
|
|
|
|
|
|
<div className="px-4 py-2 flex items-center gap-2">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="1"
|
|
|
|
|
|
max="300"
|
|
|
|
|
|
placeholder="sec"
|
|
|
|
|
|
value={customDelay}
|
|
|
|
|
|
onChange={e => setCustomDelay(e.target.value)}
|
|
|
|
|
|
onKeyDown={e => {
|
|
|
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
|
const val = parseInt(customDelay);
|
|
|
|
|
|
if (val >= 1 && val <= 300) handleEndPolling(val);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="w-20 px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
const val = parseInt(customDelay);
|
|
|
|
|
|
if (val >= 1 && val <= 300) handleEndPolling(val);
|
|
|
|
|
|
}}
|
|
|
|
|
|
disabled={!customDelay || parseInt(customDelay) < 1 || parseInt(customDelay) > 300}
|
|
|
|
|
|
className="px-3 py-1.5 text-sm bg-orange-600 dark:bg-orange-700 text-white rounded hover:bg-orange-700 dark:hover:bg-orange-800 transition disabled:opacity-40 disabled:cursor-not-allowed font-semibold"
|
|
|
|
|
|
>
|
|
|
|
|
|
Go
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<span className="text-xs text-gray-400">max 5m</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-05-07 20:44:04 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="bg-green-50 dark:bg-green-900/20 border-2 border-green-400 dark:border-green-700 rounded-lg shadow-lg p-4 sm:p-6 mb-6">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h3 className="text-lg font-semibold text-green-800 dark:text-green-200">
|
|
|
|
|
|
Ready to Vote
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<p className="text-sm text-green-600 dark:text-green-400 mt-1">
|
|
|
|
|
|
Start a new poll for the next game.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={handleStartPolling}
|
|
|
|
|
|
className="bg-green-600 dark:bg-green-700 text-white px-5 py-3 rounded-lg hover:bg-green-700 dark:hover:bg-green-800 transition font-semibold text-sm whitespace-nowrap"
|
|
|
|
|
|
>
|
|
|
|
|
|
Start Poll
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-11-03 17:56:15 -05:00
|
|
|
|
{/* 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
|
2026-05-10 20:33:00 -04:00
|
|
|
|
onClick={handleDismissGame}
|
2025-11-03 17:56:15 -05:00
|
|
|
|
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>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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
|
|
|
|
|
2025-10-30 13:27:55 -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>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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}
|
2025-10-30 13:27:55 -04:00
|
|
|
|
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}
|
2025-10-30 13:27:55 -04:00
|
|
|
|
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
|
2026-05-10 20:33:00 -04:00
|
|
|
|
onClick={handleDismissGame}
|
2025-11-03 17:56:15 -05:00
|
|
|
|
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>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
|
|
|
|
|
|
{/* 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 && (
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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}
|
2025-10-30 13:27:55 -04:00
|
|
|
|
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}
|
2026-05-07 20:44:04 -04:00
|
|
|
|
setHasPlayedGames={setHasPlayedGames}
|
|
|
|
|
|
setLeadingGame={setLeadingGame}
|
|
|
|
|
|
setPollActive={setPollActive}
|
2026-05-10 20:33:00 -04:00
|
|
|
|
pollActiveRef={pollActiveRef}
|
|
|
|
|
|
setPollResult={setPollResult}
|
|
|
|
|
|
pollStartedAtRef={pollStartedAtRef}
|
|
|
|
|
|
setSelectedGame={setSelectedGame}
|
|
|
|
|
|
setGameSource={setGameSource}
|
2025-11-03 17:56:15 -05:00
|
|
|
|
/>
|
2025-10-30 04:27:43 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 20:33:00 -04:00
|
|
|
|
function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame, setHasPlayedGames, setLeadingGame, setPollActive, pollActiveRef, setPollResult, pollStartedAtRef, setSelectedGame, setGameSource }) {
|
2026-03-20 21:05:19 -04:00
|
|
|
|
const { isAuthenticated, token } = useAuth();
|
2025-10-30 04:27:43 -04:00
|
|
|
|
const [games, setGames] = useState([]);
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
2025-10-30 13:27:55 -04:00
|
|
|
|
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
|
|
|
|
|
2026-03-20 23:34:22 -04:00
|
|
|
|
const playingGameRef = useRef(playingGame);
|
|
|
|
|
|
playingGameRef.current = playingGame;
|
|
|
|
|
|
|
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`);
|
2026-03-20 23:34:22 -04:00
|
|
|
|
const freshGames = response.data;
|
|
|
|
|
|
setGames(freshGames.reverse());
|
|
|
|
|
|
|
|
|
|
|
|
const currentPlaying = freshGames.find(g => g.status === 'playing');
|
|
|
|
|
|
const prev = playingGameRef.current;
|
|
|
|
|
|
if (currentPlaying) {
|
|
|
|
|
|
if (!prev || prev.id !== currentPlaying.id || prev.player_count !== currentPlaying.player_count) {
|
|
|
|
|
|
setPlayingGame(currentPlaying);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (prev) {
|
|
|
|
|
|
const still = freshGames.find(g => g.id === prev.id);
|
|
|
|
|
|
if (!still || still.status !== 'playing') {
|
|
|
|
|
|
setPlayingGame(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-07 20:44:04 -04:00
|
|
|
|
|
|
|
|
|
|
setHasPlayedGames(freshGames.some(g => g.status === 'played'));
|
2025-10-30 04:27:43 -04:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Failed to load session games');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
2026-03-20 23:34:22 -04:00
|
|
|
|
}, [sessionId, setPlayingGame]);
|
2025-10-30 17:52:44 -04:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadGames();
|
|
|
|
|
|
}, [sessionId, onGamesUpdate, loadGames]);
|
|
|
|
|
|
|
2026-03-20 21:05:19 -04:00
|
|
|
|
// 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();
|
2026-03-20 21:05:19 -04:00
|
|
|
|
}, 60000);
|
2025-10-30 17:52:44 -04:00
|
|
|
|
|
|
|
|
|
|
return () => clearInterval(interval);
|
|
|
|
|
|
}, [loadGames]);
|
2025-10-30 04:27:43 -04:00
|
|
|
|
|
2026-05-10 20:33:00 -04:00
|
|
|
|
// Setup WebSocket connection for real-time session updates (with ping + auto-reconnect)
|
|
|
|
|
|
const wsRef = useRef(null);
|
|
|
|
|
|
const pingIntervalRef = useRef(null);
|
|
|
|
|
|
const reconnectTimeoutRef = useRef(null);
|
|
|
|
|
|
|
|
|
|
|
|
const connectWs = useCallback(() => {
|
2026-03-20 11:47:19 -04:00
|
|
|
|
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`;
|
2026-05-10 20:33:00 -04:00
|
|
|
|
|
2025-11-03 17:56:15 -05:00
|
|
|
|
try {
|
|
|
|
|
|
const ws = new WebSocket(wsUrl);
|
2026-05-10 20:33:00 -04:00
|
|
|
|
wsRef.current = ws;
|
|
|
|
|
|
|
2025-11-03 17:56:15 -05:00
|
|
|
|
ws.onopen = () => {
|
2026-03-20 11:47:19 -04:00
|
|
|
|
console.log('[WebSocket] Connected, authenticating...');
|
|
|
|
|
|
ws.send(JSON.stringify({ type: 'auth', token }));
|
2025-11-03 17:56:15 -05:00
|
|
|
|
};
|
2026-05-10 20:33:00 -04:00
|
|
|
|
|
2025-11-03 17:56:15 -05:00
|
|
|
|
ws.onmessage = (event) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const message = JSON.parse(event.data);
|
2026-05-10 20:33:00 -04:00
|
|
|
|
|
2026-03-20 11:47:19 -04:00
|
|
|
|
if (message.type === 'auth_success') {
|
|
|
|
|
|
console.log('[WebSocket] Authenticated, subscribing to session', sessionId);
|
|
|
|
|
|
ws.send(JSON.stringify({ type: 'subscribe', sessionId: parseInt(sessionId) }));
|
2026-05-10 20:33:00 -04:00
|
|
|
|
|
|
|
|
|
|
clearInterval(pingIntervalRef.current);
|
|
|
|
|
|
pingIntervalRef.current = setInterval(() => {
|
|
|
|
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
|
|
|
|
ws.send(JSON.stringify({ type: 'ping' }));
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 30000);
|
2026-03-20 11:47:19 -04:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const reloadEvents = [
|
|
|
|
|
|
'room.connected',
|
|
|
|
|
|
'lobby.player-joined',
|
|
|
|
|
|
'lobby.updated',
|
|
|
|
|
|
'game.started',
|
|
|
|
|
|
'game.ended',
|
|
|
|
|
|
'room.disconnected',
|
|
|
|
|
|
'player-count.updated',
|
|
|
|
|
|
'game.added',
|
2026-03-20 21:05:19 -04:00
|
|
|
|
'game.status',
|
2026-03-20 11:47:19 -04:00
|
|
|
|
];
|
|
|
|
|
|
|
2026-05-10 20:33:00 -04:00
|
|
|
|
if (message.type === 'poll.start') {
|
|
|
|
|
|
pollStartedAtRef.current = message.data.pollStartedAt || new Date().toISOString();
|
2026-05-07 20:44:04 -04:00
|
|
|
|
setPollActive(true);
|
2026-05-10 20:33:00 -04:00
|
|
|
|
setPollResult(null);
|
|
|
|
|
|
setLeadingGame(null);
|
|
|
|
|
|
setPollEndingAt(null);
|
|
|
|
|
|
setShowEndPollOptions(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (message.type === 'poll.ending') {
|
|
|
|
|
|
setPollEndingAt(message.data.endsAt);
|
|
|
|
|
|
setShowEndPollOptions(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (message.type === 'poll.ending.cancelled') {
|
|
|
|
|
|
setPollEndingAt(null);
|
2026-05-07 20:44:04 -04:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-10 20:33:00 -04:00
|
|
|
|
if (message.type === 'poll.leading') {
|
|
|
|
|
|
if (pollActiveRef.current) {
|
|
|
|
|
|
setLeadingGame(message.data);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (message.type === 'voting.ended') {
|
|
|
|
|
|
setLeadingGame(null);
|
|
|
|
|
|
setPollActive(false);
|
|
|
|
|
|
setPollEndingAt(null);
|
|
|
|
|
|
setShowEndPollOptions(false);
|
|
|
|
|
|
if (message.data.winnerGameId) {
|
|
|
|
|
|
setPollResult({
|
|
|
|
|
|
gameId: message.data.winnerGameId,
|
|
|
|
|
|
label: message.data.winnerLabel,
|
|
|
|
|
|
votes: message.data.winnerVotes
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (message.type === 'game.started') {
|
2026-05-07 20:44:04 -04:00
|
|
|
|
setLeadingGame(null);
|
|
|
|
|
|
setPollActive(false);
|
2026-05-10 20:33:00 -04:00
|
|
|
|
setPollEndingAt(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (message.type === 'game.picked') {
|
|
|
|
|
|
setSelectedGame(message.data.game);
|
|
|
|
|
|
setGameSource(message.data.source || 'dice');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (message.type === 'game.dismissed') {
|
|
|
|
|
|
setSelectedGame(null);
|
|
|
|
|
|
setGameSource('dice');
|
|
|
|
|
|
return;
|
2026-05-07 20:44:04 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 11:47:19 -04:00
|
|
|
|
if (reloadEvents.includes(message.type)) {
|
|
|
|
|
|
console.log(`[WebSocket] ${message.type}:`, message.data);
|
2026-05-10 20:33:00 -04:00
|
|
|
|
if (message.type === 'game.added') {
|
|
|
|
|
|
setSelectedGame(null);
|
|
|
|
|
|
setGameSource('dice');
|
|
|
|
|
|
}
|
2025-11-03 17:56:15 -05:00
|
|
|
|
loadGames();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[WebSocket] Error parsing message:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-05-10 20:33:00 -04:00
|
|
|
|
|
2025-11-03 17:56:15 -05:00
|
|
|
|
ws.onerror = (error) => {
|
|
|
|
|
|
console.error('[WebSocket] Error:', error);
|
|
|
|
|
|
};
|
2026-05-10 20:33:00 -04:00
|
|
|
|
|
2025-11-03 17:56:15 -05:00
|
|
|
|
ws.onclose = () => {
|
2026-05-10 20:33:00 -04:00
|
|
|
|
console.log('[WebSocket] Disconnected, reconnecting in 3s...');
|
|
|
|
|
|
clearInterval(pingIntervalRef.current);
|
|
|
|
|
|
reconnectTimeoutRef.current = setTimeout(connectWs, 3000);
|
2025-11-03 17:56:15 -05:00
|
|
|
|
};
|
2026-05-10 20:33:00 -04:00
|
|
|
|
|
2025-11-03 17:56:15 -05:00
|
|
|
|
setWsConnection(ws);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[WebSocket] Failed to connect:', error);
|
2026-05-10 20:33:00 -04:00
|
|
|
|
reconnectTimeoutRef.current = setTimeout(connectWs, 3000);
|
2025-11-03 17:56:15 -05:00
|
|
|
|
}
|
2026-05-10 20:33:00 -04:00
|
|
|
|
}, [sessionId, token, loadGames, setPollActive, setPollResult, setLeadingGame, pollActiveRef, pollStartedAtRef, setSelectedGame, setGameSource]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
connectWs();
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
clearTimeout(reconnectTimeoutRef.current);
|
|
|
|
|
|
clearInterval(pingIntervalRef.current);
|
|
|
|
|
|
if (wsRef.current) {
|
|
|
|
|
|
wsRef.current.onclose = null;
|
|
|
|
|
|
wsRef.current.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [connectWs]);
|
2025-11-03 17:56:15 -05:00
|
|
|
|
|
2025-10-30 13:27:55 -04: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);
|
|
|
|
|
|
}
|
2025-10-30 13:27:55 -04:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2025-10-30 13:27:55 -04:00
|
|
|
|
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('');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-30 13:27:55 -04:00
|
|
|
|
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 ? (
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<p className="text-gray-500 dark:text-gray-400">Loading...</p>
|
2025-10-30 04:27:43 -04:00
|
|
|
|
) : games.length === 0 ? (
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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;
|
2025-10-30 13:27:55 -04:00
|
|
|
|
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>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
{getStatusBadge(game.status)}
|
2026-05-10 20:33:00 -04:00
|
|
|
|
{game.source === 'manual' || (game.manually_added === 1 && game.source !== 'poll') ? (
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<span className="text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded">
|
|
|
|
|
|
Manual
|
2026-05-10 20:33:00 -04:00
|
|
|
|
</span>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{game.source === 'poll' && (
|
|
|
|
|
|
<span className="text-xs bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200 px-2 py-1 rounded">
|
|
|
|
|
|
Poll
|
|
|
|
|
|
</span>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
)}
|
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">
|
2026-03-20 11:47:19 -04:00
|
|
|
|
{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">
|
2026-03-20 11:47:19 -04:00
|
|
|
|
📡 Monitoring...
|
2025-11-03 17:56:15 -05:00
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
2026-03-20 11:47:19 -04:00
|
|
|
|
{(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">
|
2026-03-20 11:47:19 -04:00
|
|
|
|
📡 {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 */}
|
2026-03-20 11:47:19 -04:00
|
|
|
|
{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}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2025-10-30 13:27:55 -04:00
|
|
|
|
</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>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
|
|
|
|
|
|
{/* 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>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
{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 13:27:55 -04:00
|
|
|
|
);
|
|
|
|
|
|
})}
|
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;
|
|
|
|
|
|
|