we're about to port the chrome-extension. everything else mostly works

This commit is contained in:
cottongin
2025-10-30 13:27:55 -04:00
parent 2db707961c
commit db2a8abe66
29 changed files with 2490 additions and 562 deletions

View File

@@ -1,20 +1,50 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../context/AuthContext';
import { useToast } from '../components/Toast';
import api from '../api/axios';
import { formatLocalDateTime, formatLocalDate, formatLocalTime } from '../utils/dateUtils';
function History() {
const { isAuthenticated } = useAuth();
const { error, success } = useToast();
const [sessions, setSessions] = useState([]);
const [selectedSession, setSelectedSession] = useState(null);
const [sessionGames, setSessionGames] = useState([]);
const [loading, setLoading] = useState(true);
const [showChatImport, setShowChatImport] = useState(false);
const [closingSession, setClosingSession] = useState(null);
const [showAllSessions, setShowAllSessions] = useState(false);
const [deletingSession, setDeletingSession] = useState(null);
useEffect(() => {
loadSessions();
}, []);
// Auto-select active session if navigating from picker
useEffect(() => {
if (sessions.length > 0 && !selectedSession) {
const activeSession = sessions.find(s => s.is_active === 1);
if (activeSession) {
loadSessionGames(activeSession.id);
}
}
}, [sessions]);
// Poll for updates on active session
useEffect(() => {
if (!selectedSession) return;
const currentSession = sessions.find(s => s.id === selectedSession);
if (!currentSession || currentSession.is_active !== 1) return;
// Refresh games every 3 seconds for active session
const interval = setInterval(() => {
loadSessionGames(selectedSession, true); // silent refresh
}, 3000);
return () => clearInterval(interval);
}, [selectedSession, sessions]);
const loadSessions = async () => {
try {
const response = await api.get('/sessions');
@@ -26,13 +56,17 @@ function History() {
}
};
const loadSessionGames = async (sessionId) => {
const loadSessionGames = async (sessionId, silent = false) => {
try {
const response = await api.get(`/sessions/${sessionId}/games`);
setSessionGames(response.data);
setSelectedSession(sessionId);
if (!silent) {
setSelectedSession(sessionId);
}
} catch (err) {
console.error('Failed to load session games', err);
if (!silent) {
console.error('Failed to load session games', err);
}
}
};
@@ -41,74 +75,123 @@ function History() {
await api.post(`/sessions/${sessionId}/close`, { notes });
await loadSessions();
setClosingSession(null);
if (selectedSession === sessionId) {
// Reload the session details to show updated state
loadSessionGames(sessionId);
}
success('Session ended successfully');
} catch (err) {
error('Failed to close session');
}
};
const handleDeleteSession = async (sessionId) => {
try {
await api.delete(`/sessions/${sessionId}`);
await loadSessions();
setDeletingSession(null);
if (selectedSession === sessionId) {
setSelectedSession(null);
setSessionGames([]);
}
success('Session deleted successfully');
} catch (err) {
alert('Failed to close session');
error('Failed to delete session: ' + (err.response?.data?.error || err.message));
}
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="text-xl text-gray-600">Loading...</div>
<div className="text-xl text-gray-600 dark:text-gray-400">Loading...</div>
</div>
);
}
return (
<div className="max-w-7xl mx-auto">
<h1 className="text-4xl font-bold mb-8 text-gray-800">Session History</h1>
<h1 className="text-4xl font-bold mb-8 text-gray-800 dark:text-gray-100">Session History</h1>
<div className="grid md:grid-cols-3 gap-6">
{/* Sessions List */}
<div className="md:col-span-1">
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-2xl font-semibold mb-4 text-gray-800">Sessions</h2>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-100">Sessions</h2>
{sessions.length > 3 && (
<button
onClick={() => setShowAllSessions(!showAllSessions)}
className="text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 transition"
>
{showAllSessions ? 'Show Recent' : `Show All (${sessions.length})`}
</button>
)}
</div>
{sessions.length === 0 ? (
<p className="text-gray-500">No sessions found</p>
<p className="text-gray-500 dark:text-gray-400">No sessions found</p>
) : (
<div className="space-y-2 max-h-[600px] overflow-y-auto">
{sessions.map(session => (
<div className="space-y-1 max-h-[600px] overflow-y-auto">
{(showAllSessions ? sessions : sessions.slice(0, 3)).map(session => (
<div
key={session.id}
onClick={() => loadSessionGames(session.id)}
className={`p-4 border rounded-lg cursor-pointer transition ${
className={`border rounded-lg transition ${
selectedSession === session.id
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-300 hover:border-indigo-300'
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/30'
: 'border-gray-300 dark:border-gray-600 hover:border-indigo-300 dark:hover:border-indigo-600'
}`}
>
<div className="flex justify-between items-start mb-2">
<div className="font-semibold text-gray-800">
Session #{session.id}
{/* Main session info - clickable */}
<div
onClick={() => loadSessionGames(session.id)}
className="p-3 cursor-pointer"
>
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-sm text-gray-800 dark:text-gray-100">
Session #{session.id}
</span>
{session.is_active === 1 && (
<span className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs px-2 py-0.5 rounded flex-shrink-0">
Active
</span>
)}
</div>
<div className="flex flex-wrap gap-x-2 text-xs text-gray-500 dark:text-gray-400">
<span>{formatLocalDate(session.created_at)}</span>
<span></span>
<span>{session.games_played} game{session.games_played !== 1 ? 's' : ''}</span>
</div>
</div>
</div>
{session.is_active === 1 && (
<span className="bg-green-100 text-green-800 text-xs px-2 py-1 rounded">
Active
</span>
)}
</div>
<div className="text-sm text-gray-600">
{new Date(session.created_at).toLocaleDateString()}
</div>
<div className="text-sm text-gray-500">
{session.games_played} game{session.games_played !== 1 ? 's' : ''} played
</div>
{isAuthenticated && session.is_active === 1 && (
<button
onClick={(e) => {
e.stopPropagation();
setClosingSession(session.id);
}}
className="mt-2 w-full bg-yellow-600 text-white px-3 py-1 rounded text-sm hover:bg-yellow-700 transition"
>
Close Session
</button>
{/* Action buttons for authenticated users */}
{isAuthenticated && (
<div className="px-3 pb-3 pt-0 flex gap-2">
{session.is_active === 1 ? (
<button
onClick={(e) => {
e.stopPropagation();
setClosingSession(session.id);
}}
className="w-full bg-orange-600 dark:bg-orange-700 text-white px-4 py-2 rounded text-sm hover:bg-orange-700 dark:hover:bg-orange-800 transition"
>
End Session
</button>
) : (
<button
onClick={(e) => {
e.stopPropagation();
setDeletingSession(session.id);
}}
className="w-full bg-red-600 dark:bg-red-700 text-white px-4 py-2 rounded text-sm hover:bg-red-700 dark:hover:bg-red-800 transition"
>
Delete Session
</button>
)}
</div>
)}
</div>
))}
@@ -120,22 +203,34 @@ function History() {
{/* Session Details */}
<div className="md:col-span-2">
{selectedSession ? (
<div className="bg-white rounded-lg shadow-lg p-6">
<div className="flex justify-between items-start mb-6">
<div>
<h2 className="text-2xl font-semibold text-gray-800">
Session #{selectedSession}
</h2>
<p className="text-gray-600">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-6">
<div className="flex flex-col gap-4 mb-6">
<div className="flex-1">
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-2">
<h2 className="text-xl sm:text-2xl font-semibold text-gray-800 dark:text-gray-100">
Session #{selectedSession}
</h2>
{sessions.find(s => s.id === selectedSession)?.is_active === 1 && (
<span className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs sm:text-sm px-2 sm:px-3 py-1 rounded-full font-semibold animate-pulse inline-flex items-center gap-1 w-fit">
🟢 Active
</span>
)}
</div>
<p className="text-sm sm:text-base text-gray-600 dark:text-gray-400">
{sessions.find(s => s.id === selectedSession)?.created_at &&
new Date(sessions.find(s => s.id === selectedSession).created_at).toLocaleString()}
formatLocalDateTime(sessions.find(s => s.id === selectedSession).created_at)}
</p>
{sessions.find(s => s.id === selectedSession)?.is_active === 1 && (
<p className="text-xs sm:text-sm text-gray-500 dark:text-gray-500 mt-1 italic">
Games update automatically
</p>
)}
</div>
{isAuthenticated && sessions.find(s => s.id === selectedSession)?.is_active === 1 && (
<button
onClick={() => setShowChatImport(true)}
className="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition"
className="bg-indigo-600 dark:bg-indigo-700 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 transition text-sm sm:text-base w-full sm:w-auto"
>
Import Chat Log
</button>
@@ -154,35 +249,35 @@ function History() {
)}
{sessionGames.length === 0 ? (
<p className="text-gray-500">No games played in this session</p>
<p className="text-gray-500 dark:text-gray-400">No games played in this session</p>
) : (
<div>
<h3 className="text-xl font-semibold mb-4 text-gray-700">
<h3 className="text-xl font-semibold mb-4 text-gray-700 dark:text-gray-200">
Games Played ({sessionGames.length})
</h3>
<div className="space-y-3">
{sessionGames.map((game, index) => (
<div key={game.id} className="border border-gray-200 rounded-lg p-4">
<div key={game.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-gray-700/50">
<div className="flex justify-between items-start mb-2">
<div>
<div className="font-semibold text-lg text-gray-800">
<div className="font-semibold text-lg text-gray-800 dark:text-gray-100">
{index + 1}. {game.title}
</div>
<div className="text-gray-600">{game.pack_name}</div>
<div className="text-gray-600 dark:text-gray-400">{game.pack_name}</div>
</div>
<div className="text-right">
<div className="text-sm text-gray-500">
{new Date(game.played_at).toLocaleTimeString()}
<div className="text-sm text-gray-500 dark:text-gray-400">
{formatLocalTime(game.played_at)}
</div>
{game.manually_added === 1 && (
<span className="inline-block mt-1 text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
<span className="inline-block mt-1 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>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm text-gray-600">
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm text-gray-600 dark:text-gray-400">
<div>
<span className="font-semibold">Players:</span> {game.min_players}-{game.max_players}
</div>
@@ -191,7 +286,7 @@ function History() {
</div>
<div>
<span className="font-semibold">Popularity:</span>{' '}
<span className={game.popularity_score >= 0 ? 'text-green-600' : 'text-red-600'}>
<span className={game.popularity_score >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
{game.popularity_score > 0 ? '+' : ''}{game.popularity_score}
</span>
</div>
@@ -203,41 +298,105 @@ function History() {
)}
</div>
) : (
<div className="bg-white rounded-lg shadow-lg p-6 flex items-center justify-center h-64">
<p className="text-gray-500 text-lg">Select a session to view details</p>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 flex items-center justify-center h-64">
<p className="text-gray-500 dark:text-gray-400 text-lg">Select a session to view details</p>
</div>
)}
</div>
</div>
{/* Close Session Modal */}
{/* End Session Modal */}
{closingSession && (
<CloseSessionModal
<EndSessionModal
sessionId={closingSession}
sessionGames={closingSession === selectedSession ? sessionGames : []}
onClose={() => setClosingSession(null)}
onConfirm={handleCloseSession}
onShowChatImport={() => {
setShowChatImport(true);
if (closingSession !== selectedSession) {
loadSessionGames(closingSession);
}
}}
/>
)}
{/* Delete Confirmation Modal */}
{deletingSession && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-8 max-w-md w-full">
<h2 className="text-2xl font-bold mb-4 text-red-600 dark:text-red-400">Delete Session?</h2>
<p className="text-gray-700 dark:text-gray-300 mb-6">
Are you sure you want to delete Session #{deletingSession}?
This will permanently delete all games and chat logs associated with this session. This action cannot be undone.
</p>
<div className="flex gap-4">
<button
onClick={() => handleDeleteSession(deletingSession)}
className="flex-1 bg-red-600 dark:bg-red-700 text-white py-3 rounded-lg hover:bg-red-700 dark:hover:bg-red-800 transition font-semibold"
>
Delete Permanently
</button>
<button
onClick={() => setDeletingSession(null)}
className="flex-1 bg-gray-600 dark:bg-gray-700 text-white py-3 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition"
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
);
}
function CloseSessionModal({ sessionId, onClose, onConfirm }) {
function EndSessionModal({ sessionId, sessionGames, onClose, onConfirm, onShowChatImport }) {
const [notes, setNotes] = useState('');
// Check if any games have been voted on (popularity != 0)
const hasPopularityData = sessionGames.some(game => game.popularity_score !== 0);
const showPopularityWarning = sessionGames.length > 0 && !hasPopularityData;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-8 max-w-md w-full">
<h2 className="text-2xl font-bold mb-4">Close Session #{sessionId}</h2>
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-8 max-w-md w-full">
<h2 className="text-2xl font-bold mb-4 dark:text-gray-100">End Session #{sessionId}</h2>
{/* Popularity Warning */}
{showPopularityWarning && (
<div className="mb-4 p-4 bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-300 dark:border-yellow-700 rounded-lg">
<div className="flex items-start gap-2">
<span className="text-yellow-600 dark:text-yellow-400 text-xl"></span>
<div className="flex-1">
<p className="font-semibold text-yellow-800 dark:text-yellow-200 mb-1">
No Popularity Data
</p>
<p className="text-sm text-yellow-700 dark:text-yellow-300 mb-3">
You haven't imported chat reactions yet. Import now to track which games your players loved!
</p>
<button
onClick={() => {
onClose();
onShowChatImport();
}}
className="text-sm bg-yellow-600 dark:bg-yellow-700 text-white px-4 py-2 rounded hover:bg-yellow-700 dark:hover:bg-yellow-800 transition"
>
Import Chat Log
</button>
</div>
</div>
</div>
)}
<div className="mb-4">
<label className="block text-gray-700 font-semibold mb-2">
<label className="block text-gray-700 dark:text-gray-300 font-semibold mb-2">
Session Notes (optional)
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg h-32"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg h-32 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="Add any notes about this session..."
/>
</div>
@@ -245,13 +404,13 @@ function CloseSessionModal({ sessionId, onClose, onConfirm }) {
<div className="flex gap-4">
<button
onClick={() => onConfirm(sessionId, notes)}
className="flex-1 bg-indigo-600 text-white py-3 rounded-lg hover:bg-indigo-700 transition"
className="flex-1 bg-orange-600 dark:bg-orange-700 text-white py-3 rounded-lg hover:bg-orange-700 dark:hover:bg-orange-800 transition"
>
Close Session
End Session
</button>
<button
onClick={onClose}
className="flex-1 bg-gray-600 text-white py-3 rounded-lg hover:bg-gray-700 transition"
className="flex-1 bg-gray-600 dark:bg-gray-700 text-white py-3 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition"
>
Cancel
</button>
@@ -265,10 +424,11 @@ function ChatImportPanel({ sessionId, onClose, onImportComplete }) {
const [chatData, setChatData] = useState('');
const [importing, setImporting] = useState(false);
const [result, setResult] = useState(null);
const { error, success } = useToast();
const handleImport = async () => {
if (!chatData.trim()) {
alert('Please enter chat data');
error('Please enter chat data');
return;
}
@@ -281,45 +441,46 @@ function ChatImportPanel({ sessionId, onClose, onImportComplete }) {
chatData: parsedData
});
setResult(response.data);
success('Chat log imported successfully');
setTimeout(() => {
onImportComplete();
}, 2000);
} catch (err) {
alert('Import failed: ' + (err.response?.data?.error || err.message));
error('Import failed: ' + (err.response?.data?.error || err.message));
} finally {
setImporting(false);
}
};
return (
<div className="bg-gray-50 border border-gray-300 rounded-lg p-6 mb-6">
<h3 className="text-xl font-semibold mb-4">Import Chat Log</h3>
<div className="bg-gray-50 dark:bg-gray-700/50 border border-gray-300 dark:border-gray-600 rounded-lg p-6 mb-6">
<h3 className="text-xl font-semibold mb-4 dark:text-gray-100">Import Chat Log</h3>
<p className="text-sm text-gray-600 mb-4">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Paste JSON array with format: [{"{"}"username": "...", "message": "...", "timestamp": "..."{"}"}]
<br />
The system will detect "thisgame++" and "thisgame--" patterns and update game popularity.
</p>
<div className="mb-4">
<label className="block text-gray-700 font-semibold mb-2">Chat JSON Data</label>
<label className="block text-gray-700 dark:text-gray-300 font-semibold mb-2">Chat JSON Data</label>
<textarea
value={chatData}
onChange={(e) => setChatData(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg h-48 font-mono text-sm"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg h-48 font-mono text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
placeholder='[{"username":"Alice","message":"thisgame++","timestamp":"2024-01-01T12:00:00Z"}]'
disabled={importing}
/>
</div>
{result && (
<div className="mb-4 p-4 bg-green-50 border border-green-300 rounded-lg">
<p className="font-semibold text-green-800">Import Successful!</p>
<p className="text-sm text-green-700">
<div className="mb-4 p-4 bg-green-50 dark:bg-green-900/30 border border-green-300 dark:border-green-700 rounded-lg">
<p className="font-semibold text-green-800 dark:text-green-200">Import Successful!</p>
<p className="text-sm text-green-700 dark:text-green-300">
Imported {result.messagesImported} messages, processed {result.votesProcessed} votes
</p>
{result.votesByGame && Object.keys(result.votesByGame).length > 0 && (
<div className="mt-2 text-sm">
<div className="mt-2 text-sm text-green-700 dark:text-green-300">
<p className="font-semibold">Votes by game:</p>
<ul className="list-disc list-inside">
{Object.values(result.votesByGame).map((vote, i) => (
@@ -337,13 +498,13 @@ function ChatImportPanel({ sessionId, onClose, onImportComplete }) {
<button
onClick={handleImport}
disabled={importing}
className="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition disabled:bg-gray-400"
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"
>
{importing ? 'Importing...' : 'Import'}
</button>
<button
onClick={onClose}
className="bg-gray-600 text-white px-6 py-2 rounded-lg hover:bg-gray-700 transition"
className="bg-gray-600 dark:bg-gray-700 text-white px-6 py-2 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition"
>
Close
</button>

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../api/axios';
import { formatLocalDateTime, formatLocalTime } from '../utils/dateUtils';
function Home() {
const { isAuthenticated } = useAuth();
@@ -13,14 +14,28 @@ function Home() {
loadActiveSession();
}, []);
// Auto-refresh for active session
useEffect(() => {
if (!activeSession) return;
// Refresh games every 3 seconds for active session
const interval = setInterval(() => {
loadSessionGames(activeSession.id, true); // silent refresh
}, 3000);
return () => clearInterval(interval);
}, [activeSession]);
const loadActiveSession = async () => {
try {
const response = await api.get('/sessions/active');
setActiveSession(response.data);
if (response.data?.id) {
const gamesResponse = await api.get(`/sessions/${response.data.id}/games`);
setSessionGames(gamesResponse.data);
// Handle both old format (direct session) and new format (session: null)
const session = response.data?.session !== undefined ? response.data.session : response.data;
setActiveSession(session);
if (session?.id) {
await loadSessionGames(session.id);
}
} catch (error) {
// No active session is okay
@@ -30,38 +45,47 @@ function Home() {
}
};
const loadSessionGames = async (sessionId, silent = false) => {
try {
const gamesResponse = await api.get(`/sessions/${sessionId}/games`);
// Reverse chronological order (most recent first)
setSessionGames(gamesResponse.data.reverse());
} catch (error) {
if (!silent) {
console.error('Failed to load session games', error);
}
}
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="text-xl text-gray-600">Loading...</div>
<div className="text-xl text-gray-600 dark:text-gray-400">Loading...</div>
</div>
);
}
return (
<div className="max-w-4xl mx-auto">
<h1 className="text-4xl font-bold mb-8 text-gray-800">
Welcome to Jackbox Game Picker
</h1>
{activeSession ? (
<div className="bg-white rounded-lg shadow-lg p-6 mb-8">
<div className="flex justify-between items-start mb-4">
<div>
<h2 className="text-2xl font-semibold text-green-600 mb-2">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-6 mb-8">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-4 mb-4">
<div className="flex-1">
<h2 className="text-xl sm:text-2xl font-semibold text-green-600 dark:text-green-400 mb-2">
Live Session Active
</h2>
<p className="text-gray-600">
Started: {new Date(activeSession.created_at).toLocaleString()}
<p className="text-sm sm:text-base text-gray-600 dark:text-gray-400">
Started: {formatLocalDateTime(activeSession.created_at)}
</p>
{activeSession.notes && (
<p className="text-gray-600 mt-2">Notes: {activeSession.notes}</p>
<p className="text-sm sm:text-base text-gray-600 dark:text-gray-400 mt-2">Notes: {activeSession.notes}</p>
)}
</div>
{isAuthenticated && (
<Link
to="/picker"
className="bg-indigo-600 text-white px-6 py-3 rounded-lg hover:bg-indigo-700 transition"
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 text-center sm:text-left whitespace-nowrap"
>
Pick a Game
</Link>
@@ -70,50 +94,79 @@ function Home() {
{sessionGames.length > 0 && (
<div className="mt-6">
<h3 className="text-xl font-semibold mb-4">Games Played This Session</h3>
<h3 className="text-lg sm:text-xl font-semibold mb-4 text-gray-800 dark:text-gray-100">Games Played This Session</h3>
<div className="space-y-2">
{sessionGames.map((game, index) => (
{sessionGames.map((game, index) => {
const displayIndex = sessionGames.length - index;
const isPlaying = game.status === 'playing';
const isSkipped = game.status === 'skipped';
return (
<div
key={game.id}
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg"
className={`flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 p-3 sm:p-4 rounded-lg transition ${
isPlaying
? 'bg-green-50 dark:bg-green-900/20 border-2 border-green-300 dark:border-green-700'
: isSkipped
? 'bg-gray-100 dark:bg-gray-700/50'
: 'bg-gray-50 dark:bg-gray-700'
}`}
>
<div>
<span className="font-semibold text-gray-700">
{index + 1}. {game.title}
</span>
<span className="text-gray-500 ml-2">({game.pack_name})</span>
{game.manually_added === 1 && (
<span className="ml-2 text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
Manual
<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 break-words ${
isSkipped
? 'text-gray-500 dark:text-gray-500 line-through'
: 'text-gray-700 dark:text-gray-200'
}`}>
{displayIndex}. {game.title}
</span>
)}
{isPlaying && (
<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>
)}
{isSkipped && (
<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>
)}
</div>
<div className="text-xs sm:text-sm text-gray-500 dark:text-gray-400 mt-1">
({game.pack_name})
{game.manually_added === 1 && (
<span className="ml-2 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>
<span className="text-sm text-gray-500">
{new Date(game.played_at).toLocaleTimeString()}
<span className="text-xs sm:text-sm text-gray-500 dark:text-gray-400 flex-shrink-0">
{formatLocalTime(game.played_at)}
</span>
</div>
))}
);
})}
</div>
</div>
)}
</div>
) : (
<div className="bg-white rounded-lg shadow-lg p-6 mb-8">
<h2 className="text-2xl font-semibold text-gray-700 mb-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-8">
<h2 className="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-4">
No Active Session
</h2>
<p className="text-gray-600 mb-4">
<p className="text-gray-600 dark:text-gray-400 mb-4">
There is currently no game session in progress.
</p>
{isAuthenticated ? (
<Link
to="/picker"
className="inline-block bg-indigo-600 text-white px-6 py-3 rounded-lg hover:bg-indigo-700 transition"
className="inline-block 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"
>
Start a New Session
</Link>
) : (
<p className="text-gray-500">
<p className="text-gray-500 dark:text-gray-400">
Admin access required to start a new session.
</p>
)}
@@ -123,12 +176,12 @@ function Home() {
<div className="grid md:grid-cols-2 gap-6">
<Link
to="/history"
className="bg-white rounded-lg shadow-lg p-6 hover:shadow-xl transition"
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 hover:shadow-xl transition"
>
<h3 className="text-xl font-semibold text-gray-800 mb-2">
<h3 className="text-xl font-semibold text-gray-800 dark:text-gray-100 mb-2">
Session History
</h3>
<p className="text-gray-600">
<p className="text-gray-600 dark:text-gray-300">
View past gaming sessions and the games that were played
</p>
</Link>
@@ -136,12 +189,12 @@ function Home() {
{isAuthenticated && (
<Link
to="/manager"
className="bg-white rounded-lg shadow-lg p-6 hover:shadow-xl transition"
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 hover:shadow-xl transition"
>
<h3 className="text-xl font-semibold text-gray-800 mb-2">
<h3 className="text-xl font-semibold text-gray-800 dark:text-gray-100 mb-2">
Game Manager
</h3>
<p className="text-gray-600">
<p className="text-gray-600 dark:text-gray-300">
Manage games, packs, and view statistics
</p>
</Link>

View File

@@ -32,12 +32,12 @@ function Login() {
return (
<div className="max-w-md mx-auto">
<div className="bg-white rounded-lg shadow-lg p-8">
<h1 className="text-3xl font-bold mb-6 text-gray-800">Admin Login</h1>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
<h1 className="text-3xl font-bold mb-6 text-gray-800 dark:text-gray-100">Admin Login</h1>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label htmlFor="key" className="block text-gray-700 font-semibold mb-2">
<label htmlFor="key" className="block text-gray-700 dark:text-gray-300 font-semibold mb-2">
Admin Key
</label>
<input
@@ -45,7 +45,7 @@ function Login() {
id="key"
value={key}
onChange={(e) => setKey(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
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="Enter admin key"
required
disabled={loading}
@@ -53,7 +53,7 @@ function Login() {
</div>
{error && (
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-200 rounded">
{error}
</div>
)}
@@ -61,13 +61,13 @@ function Login() {
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 text-white py-3 rounded-lg hover:bg-indigo-700 transition disabled:bg-gray-400 disabled:cursor-not-allowed"
className="w-full bg-indigo-600 text-white py-3 rounded-lg hover:bg-indigo-700 transition disabled:bg-gray-400 dark:disabled:bg-gray-600 disabled:cursor-not-allowed"
>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
<p className="mt-4 text-sm text-gray-600 text-center">
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400 text-center">
Admin privileges are required to manage games and sessions
</p>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -2,9 +2,11 @@ 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 } = useAuth();
const { isAuthenticated, loading: authLoading } = useAuth();
const navigate = useNavigate();
const [activeSession, setActiveSession] = useState(null);
@@ -23,26 +25,48 @@ function Picker() {
// 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, navigate]);
}, [isAuthenticated, authLoading, navigate]);
const loadData = async () => {
try {
// Load active session or create one
try {
const sessionResponse = await api.get('/sessions/active');
setActiveSession(sessionResponse.data);
} catch (err) {
// No active session, 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', {});
setActiveSession(newSession.data);
session = newSession.data;
}
setActiveSession(session);
// Load all games for manual selection
const gamesResponse = await api.get('/games');
@@ -54,6 +78,50 @@ function Picker() {
}
};
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;
@@ -66,7 +134,8 @@ function Picker() {
playerCount: playerCount ? parseInt(playerCount) : undefined,
drawing: drawingFilter !== 'both' ? drawingFilter : undefined,
length: lengthFilter || undefined,
familyFriendly: familyFriendlyFilter ? familyFriendlyFilter === 'yes' : undefined
familyFriendly: familyFriendlyFilter ? familyFriendlyFilter === 'yes' : undefined,
excludePlayed: excludePlayedGames
});
setSelectedGame(response.data.game);
@@ -87,8 +156,8 @@ function Picker() {
manually_added: false
});
// Reload data
await loadData();
// Trigger games list refresh
setGamesUpdateTrigger(prev => prev + 1);
setSelectedGame(null);
setError('');
} catch (err) {
@@ -105,8 +174,8 @@ function Picker() {
manually_added: true
});
// Reload data
await loadData();
// Trigger games list refresh
setGamesUpdateTrigger(prev => prev + 1);
setManualGameId('');
setShowManualSelect(false);
setError('');
@@ -115,10 +184,59 @@ function Picker() {
}
};
if (loading) {
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">Loading...</div>
<div className="text-xl text-gray-600 dark:text-gray-400">Loading...</div>
</div>
);
}
@@ -126,7 +244,7 @@ function Picker() {
if (!activeSession) {
return (
<div className="max-w-4xl mx-auto">
<div className="bg-red-100 border border-red-400 text-red-700 p-4 rounded">
<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>
@@ -135,141 +253,298 @@ function Picker() {
return (
<div className="max-w-6xl mx-auto">
<h1 className="text-4xl font-bold mb-8 text-gray-800">Game Picker</h1>
<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-6">
{/* Filters Panel */}
<div className="md:col-span-1">
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-2xl font-semibold mb-4 text-gray-800">Filters</h2>
<div className="space-y-4">
<div>
<label className="block text-gray-700 font-semibold mb-2">
Player Count
</label>
<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="number"
min="1"
max="100"
value={playerCount}
onChange={(e) => setPlayerCount(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Any"
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>
<div>
<label className="block text-gray-700 font-semibold mb-2">
Drawing Games
</label>
<select
value={drawingFilter}
onChange={(e) => setDrawingFilter(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
<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"
>
<option value="both">Both</option>
<option value="only">Only Drawing</option>
<option value="exclude">No Drawing</option>
</select>
{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-gray-700 font-semibold mb-2">
<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-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm"
>
<option value="">Any</option>
<option value="short">Short (15 min)</option>
<option value="short">Short &lt;15 min</option>
<option value="medium">Medium (16-25 min)</option>
<option value="long">Long (>25 min)</option>
<option value="long">Long &gt;25 min</option>
</select>
</div>
<div>
<label className="block text-gray-700 font-semibold mb-2">
Family Friendly
</label>
<select
value={familyFriendlyFilter}
onChange={(e) => setFamilyFriendlyFilter(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="">Any</option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
{/* 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>
<button
onClick={handlePickGame}
disabled={picking}
className="w-full bg-indigo-600 text-white py-3 rounded-lg hover:bg-indigo-700 transition disabled:bg-gray-400 disabled:cursor-not-allowed font-semibold text-lg"
>
{picking ? 'Rolling...' : '🎲 Roll the Dice'}
</button>
<button
onClick={() => setShowManualSelect(!showManualSelect)}
className="w-full bg-gray-600 text-white py-2 rounded-lg hover:bg-gray-700 transition"
>
{showManualSelect ? 'Cancel' : 'Manual Selection'}
</button>
</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 border border-red-400 text-red-700 p-4 rounded mb-4">
<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 rounded-lg shadow-lg p-8 mb-6">
<h2 className="text-3xl font-bold mb-4 text-gray-800">
<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-xl text-gray-600 mb-4">{selectedGame.pack_name}</p>
<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-4 mb-6">
<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">Players:</span>
<span className="ml-2 text-gray-600">
<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">Length:</span>
<span className="ml-2 text-gray-600">
<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">Type:</span>
<span className="ml-2 text-gray-600">
<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">Family Friendly:</span>
<span className="ml-2 text-gray-600">
<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">Play Count:</span>
<span className="ml-2 text-gray-600">{selectedGame.play_count}</span>
<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">Popularity:</span>
<span className="ml-2 text-gray-600">
<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>
@@ -279,30 +554,65 @@ function Picker() {
<div className="flex gap-4">
<button
onClick={handleAcceptGame}
className="flex-1 bg-green-600 text-white py-3 rounded-lg hover:bg-green-700 transition font-semibold"
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 text-white py-3 rounded-lg hover:bg-yellow-700 transition font-semibold"
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 rounded-lg shadow-lg p-6 mb-6">
<h3 className="text-xl font-semibold mb-4 text-gray-800">
<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 gap-4">
<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 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
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) => (
@@ -314,7 +624,7 @@ function Picker() {
<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"
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>
@@ -323,25 +633,28 @@ function Picker() {
)}
{/* Session info and games */}
<SessionInfo sessionId={activeSession.id} />
<SessionInfo sessionId={activeSession.id} onGamesUpdate={gamesUpdateTrigger} />
</div>
</div>
</div>
);
}
function SessionInfo({ sessionId }) {
function SessionInfo({ sessionId, onGamesUpdate }) {
const { isAuthenticated } = useAuth();
const [games, setGames] = useState([]);
const [loading, setLoading] = useState(true);
const [confirmingRemove, setConfirmingRemove] = useState(null);
useEffect(() => {
loadGames();
}, [sessionId]);
}, [sessionId, onGamesUpdate]);
const loadGames = async () => {
try {
const response = await api.get(`/sessions/${sessionId}/games`);
setGames(response.data);
// Reverse chronological order (most recent first)
setGames(response.data.reverse());
} catch (err) {
console.error('Failed to load session games');
} finally {
@@ -349,35 +662,145 @@ function SessionInfo({ sessionId }) {
}
};
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 rounded-lg shadow-lg p-6">
<h3 className="text-xl font-semibold mb-4 text-gray-800">
<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">Loading...</p>
<p className="text-gray-500 dark:text-gray-400">Loading...</p>
) : games.length === 0 ? (
<p className="text-gray-500">No games played yet. Pick a game to get started!</p>
<p className="text-gray-500 dark:text-gray-400">No games played yet. Pick a game to get started!</p>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{games.map((game, index) => (
<div key={game.id} className="flex items-center justify-between p-3 bg-gray-50 rounded">
<div>
<span className="font-semibold text-gray-700">
{index + 1}. {game.title}
</span>
<span className="text-gray-500 ml-2 text-sm">({game.pack_name})</span>
{game.manually_added === 1 && (
<span className="ml-2 text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
Manual
</span>
)}
{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>
<span className="text-sm text-gray-500">
{new Date(game.played_at).toLocaleTimeString()}
</span>
{/* 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>