initial commit

This commit is contained in:
cottongin
2025-10-30 04:27:43 -04:00
commit 2db707961c
34 changed files with 3487 additions and 0 deletions

76
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,76 @@
import React from 'react';
import { Routes, Route, Link } from 'react-router-dom';
import { useAuth } from './context/AuthContext';
import Home from './pages/Home';
import Login from './pages/Login';
import Picker from './pages/Picker';
import Manager from './pages/Manager';
import History from './pages/History';
function App() {
const { isAuthenticated, logout } = useAuth();
return (
<div className="min-h-screen bg-gray-100">
{/* Navigation */}
<nav className="bg-indigo-600 text-white shadow-lg">
<div className="container mx-auto px-4">
<div className="flex justify-between items-center py-4">
<Link to="/" className="text-2xl font-bold">
Jackbox Game Picker
</Link>
<div className="flex gap-4 items-center">
<Link to="/" className="hover:text-indigo-200 transition">
Home
</Link>
<Link to="/history" className="hover:text-indigo-200 transition">
History
</Link>
{isAuthenticated && (
<>
<Link to="/picker" className="hover:text-indigo-200 transition">
Picker
</Link>
<Link to="/manager" className="hover:text-indigo-200 transition">
Manager
</Link>
<button
onClick={logout}
className="bg-indigo-700 hover:bg-indigo-800 px-4 py-2 rounded transition"
>
Logout
</button>
</>
)}
{!isAuthenticated && (
<Link
to="/login"
className="bg-indigo-700 hover:bg-indigo-800 px-4 py-2 rounded transition"
>
Admin Login
</Link>
)}
</div>
</div>
</div>
</nav>
{/* Main Content */}
<main className="container mx-auto px-4 py-8">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/history" element={<History />} />
<Route path="/picker" element={<Picker />} />
<Route path="/manager" element={<Manager />} />
</Routes>
</main>
</div>
);
}
export default App;

17
frontend/src/api/axios.js Normal file
View File

@@ -0,0 +1,17 @@
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
});
// Add token to requests if available
api.interceptors.request.use((config) => {
const token = localStorage.getItem('adminToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default api;

View File

@@ -0,0 +1,70 @@
import React, { createContext, useState, useContext, useEffect } from 'react';
import axios from 'axios';
const AuthContext = createContext();
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
export const AuthProvider = ({ children }) => {
const [token, setToken] = useState(localStorage.getItem('adminToken'));
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
const verifyToken = async () => {
if (token) {
try {
await axios.post('/api/auth/verify', {}, {
headers: { Authorization: `Bearer ${token}` }
});
setIsAuthenticated(true);
} catch (error) {
console.error('Token verification failed:', error);
logout();
}
}
setLoading(false);
};
verifyToken();
}, [token]);
const login = async (key) => {
try {
const response = await axios.post('/api/auth/login', { key });
const newToken = response.data.token;
localStorage.setItem('adminToken', newToken);
setToken(newToken);
setIsAuthenticated(true);
return { success: true };
} catch (error) {
return {
success: false,
error: error.response?.data?.error || 'Login failed'
};
}
};
const logout = () => {
localStorage.removeItem('adminToken');
setToken(null);
setIsAuthenticated(false);
};
const value = {
token,
isAuthenticated,
loading,
login,
logout
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

18
frontend/src/index.css Normal file
View File

@@ -0,0 +1,18 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

17
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import { AuthProvider } from './context/AuthContext';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
);

View File

@@ -0,0 +1,356 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../context/AuthContext';
import api from '../api/axios';
function History() {
const { isAuthenticated } = useAuth();
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);
useEffect(() => {
loadSessions();
}, []);
const loadSessions = async () => {
try {
const response = await api.get('/sessions');
setSessions(response.data);
} catch (err) {
console.error('Failed to load sessions', err);
} finally {
setLoading(false);
}
};
const loadSessionGames = async (sessionId) => {
try {
const response = await api.get(`/sessions/${sessionId}/games`);
setSessionGames(response.data);
setSelectedSession(sessionId);
} catch (err) {
console.error('Failed to load session games', err);
}
};
const handleCloseSession = async (sessionId, notes) => {
try {
await api.post(`/sessions/${sessionId}/close`, { notes });
await loadSessions();
setClosingSession(null);
if (selectedSession === sessionId) {
setSelectedSession(null);
setSessionGames([]);
}
} catch (err) {
alert('Failed to close session');
}
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="text-xl text-gray-600">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>
<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>
{sessions.length === 0 ? (
<p className="text-gray-500">No sessions found</p>
) : (
<div className="space-y-2 max-h-[600px] overflow-y-auto">
{sessions.map(session => (
<div
key={session.id}
onClick={() => loadSessionGames(session.id)}
className={`p-4 border rounded-lg cursor-pointer transition ${
selectedSession === session.id
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-300 hover:border-indigo-300'
}`}
>
<div className="flex justify-between items-start mb-2">
<div className="font-semibold text-gray-800">
Session #{session.id}
</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>
)}
</div>
))}
</div>
)}
</div>
</div>
{/* 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">
{sessions.find(s => s.id === selectedSession)?.created_at &&
new Date(sessions.find(s => s.id === selectedSession).created_at).toLocaleString()}
</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"
>
Import Chat Log
</button>
)}
</div>
{showChatImport && (
<ChatImportPanel
sessionId={selectedSession}
onClose={() => setShowChatImport(false)}
onImportComplete={() => {
loadSessionGames(selectedSession);
setShowChatImport(false);
}}
/>
)}
{sessionGames.length === 0 ? (
<p className="text-gray-500">No games played in this session</p>
) : (
<div>
<h3 className="text-xl font-semibold mb-4 text-gray-700">
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 className="flex justify-between items-start mb-2">
<div>
<div className="font-semibold text-lg text-gray-800">
{index + 1}. {game.title}
</div>
<div className="text-gray-600">{game.pack_name}</div>
</div>
<div className="text-right">
<div className="text-sm text-gray-500">
{new Date(game.played_at).toLocaleTimeString()}
</div>
{game.manually_added === 1 && (
<span className="inline-block mt-1 text-xs bg-yellow-100 text-yellow-800 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>
<span className="font-semibold">Players:</span> {game.min_players}-{game.max_players}
</div>
<div>
<span className="font-semibold">Type:</span> {game.game_type || 'N/A'}
</div>
<div>
<span className="font-semibold">Popularity:</span>{' '}
<span className={game.popularity_score >= 0 ? 'text-green-600' : 'text-red-600'}>
{game.popularity_score > 0 ? '+' : ''}{game.popularity_score}
</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
</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>
)}
</div>
</div>
{/* Close Session Modal */}
{closingSession && (
<CloseSessionModal
sessionId={closingSession}
onClose={() => setClosingSession(null)}
onConfirm={handleCloseSession}
/>
)}
</div>
);
}
function CloseSessionModal({ sessionId, onClose, onConfirm }) {
const [notes, setNotes] = useState('');
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="mb-4">
<label className="block text-gray-700 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"
placeholder="Add any notes about this session..."
/>
</div>
<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"
>
Close Session
</button>
<button
onClick={onClose}
className="flex-1 bg-gray-600 text-white py-3 rounded-lg hover:bg-gray-700 transition"
>
Cancel
</button>
</div>
</div>
</div>
);
}
function ChatImportPanel({ sessionId, onClose, onImportComplete }) {
const [chatData, setChatData] = useState('');
const [importing, setImporting] = useState(false);
const [result, setResult] = useState(null);
const handleImport = async () => {
if (!chatData.trim()) {
alert('Please enter chat data');
return;
}
setImporting(true);
setResult(null);
try {
const parsedData = JSON.parse(chatData);
const response = await api.post(`/sessions/${sessionId}/chat-import`, {
chatData: parsedData
});
setResult(response.data);
setTimeout(() => {
onImportComplete();
}, 2000);
} catch (err) {
alert('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>
<p className="text-sm text-gray-600 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>
<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"
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">
Imported {result.messagesImported} messages, processed {result.votesProcessed} votes
</p>
{result.votesByGame && Object.keys(result.votesByGame).length > 0 && (
<div className="mt-2 text-sm">
<p className="font-semibold">Votes by game:</p>
<ul className="list-disc list-inside">
{Object.values(result.votesByGame).map((vote, i) => (
<li key={i}>
{vote.title}: +{vote.upvotes} / -{vote.downvotes}
</li>
))}
</ul>
</div>
)}
</div>
)}
<div className="flex gap-4">
<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"
>
{importing ? 'Importing...' : 'Import'}
</button>
<button
onClick={onClose}
className="bg-gray-600 text-white px-6 py-2 rounded-lg hover:bg-gray-700 transition"
>
Close
</button>
</div>
</div>
);
}
export default History;

155
frontend/src/pages/Home.jsx Normal file
View File

@@ -0,0 +1,155 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../api/axios';
function Home() {
const { isAuthenticated } = useAuth();
const [activeSession, setActiveSession] = useState(null);
const [sessionGames, setSessionGames] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadActiveSession();
}, []);
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);
}
} catch (error) {
// No active session is okay
setActiveSession(null);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="text-xl text-gray-600">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">
Live Session Active
</h2>
<p className="text-gray-600">
Started: {new Date(activeSession.created_at).toLocaleString()}
</p>
{activeSession.notes && (
<p className="text-gray-600 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"
>
Pick a Game
</Link>
)}
</div>
{sessionGames.length > 0 && (
<div className="mt-6">
<h3 className="text-xl font-semibold mb-4">Games Played This Session</h3>
<div className="space-y-2">
{sessionGames.map((game, index) => (
<div
key={game.id}
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg"
>
<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
</span>
)}
</div>
<span className="text-sm text-gray-500">
{new Date(game.played_at).toLocaleTimeString()}
</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">
No Active Session
</h2>
<p className="text-gray-600 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"
>
Start a New Session
</Link>
) : (
<p className="text-gray-500">
Admin access required to start a new session.
</p>
)}
</div>
)}
<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"
>
<h3 className="text-xl font-semibold text-gray-800 mb-2">
Session History
</h3>
<p className="text-gray-600">
View past gaming sessions and the games that were played
</p>
</Link>
{isAuthenticated && (
<Link
to="/manager"
className="bg-white rounded-lg shadow-lg p-6 hover:shadow-xl transition"
>
<h3 className="text-xl font-semibold text-gray-800 mb-2">
Game Manager
</h3>
<p className="text-gray-600">
Manage games, packs, and view statistics
</p>
</Link>
)}
</div>
</div>
);
}
export default Home;

View File

@@ -0,0 +1,79 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
function Login() {
const [key, setKey] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login, isAuthenticated } = useAuth();
const navigate = useNavigate();
React.useEffect(() => {
if (isAuthenticated) {
navigate('/');
}
}, [isAuthenticated, navigate]);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
const result = await login(key);
if (result.success) {
navigate('/');
} else {
setError(result.error);
setLoading(false);
}
};
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>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label htmlFor="key" className="block text-gray-700 font-semibold mb-2">
Admin Key
</label>
<input
type="password"
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"
placeholder="Enter admin key"
required
disabled={loading}
/>
</div>
{error && (
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
{error}
</div>
)}
<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"
>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
<p className="mt-4 text-sm text-gray-600 text-center">
Admin privileges are required to manage games and sessions
</p>
</div>
</div>
);
}
export default Login;

View File

@@ -0,0 +1,502 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../api/axios';
function Manager() {
const { isAuthenticated } = useAuth();
const navigate = useNavigate();
const [games, setGames] = useState([]);
const [packs, setPacks] = useState([]);
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [selectedPack, setSelectedPack] = useState('all');
const [editingGame, setEditingGame] = useState(null);
const [showAddGame, setShowAddGame] = useState(false);
const [showImportExport, setShowImportExport] = useState(false);
useEffect(() => {
if (!isAuthenticated) {
navigate('/login');
return;
}
loadData();
}, [isAuthenticated, navigate]);
const loadData = async () => {
try {
const [gamesRes, packsRes, statsRes] = await Promise.all([
api.get('/games'),
api.get('/games/meta/packs'),
api.get('/stats')
]);
setGames(gamesRes.data);
setPacks(packsRes.data);
setStats(statsRes.data);
} catch (err) {
console.error('Failed to load data', err);
} finally {
setLoading(false);
}
};
const handleToggleGame = async (gameId) => {
try {
await api.patch(`/games/${gameId}/toggle`);
await loadData();
} catch (err) {
alert('Failed to toggle game');
}
};
const handleTogglePack = async (packName, enabled) => {
try {
await api.patch(`/games/packs/${encodeURIComponent(packName)}/toggle`, { enabled });
await loadData();
} catch (err) {
alert('Failed to toggle pack');
}
};
const handleDeleteGame = async (gameId) => {
if (!confirm('Are you sure you want to delete this game?')) return;
try {
await api.delete(`/games/${gameId}`);
await loadData();
} catch (err) {
alert('Failed to delete game');
}
};
const handleExportCSV = async () => {
try {
const response = await api.get('/games/export/csv', {
responseType: 'blob'
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', 'games-export.csv');
document.body.appendChild(link);
link.click();
link.remove();
} catch (err) {
alert('Failed to export CSV');
}
};
const filteredGames = selectedPack === 'all'
? games
: games.filter(g => g.pack_name === selectedPack);
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="text-xl text-gray-600">Loading...</div>
</div>
);
}
return (
<div className="max-w-7xl mx-auto">
<h1 className="text-4xl font-bold mb-8 text-gray-800">Game Manager</h1>
{/* Statistics */}
{stats && (
<div className="grid md:grid-cols-4 gap-4 mb-8">
<div className="bg-white rounded-lg shadow p-6">
<div className="text-3xl font-bold text-indigo-600">{stats.games.count}</div>
<div className="text-gray-600">Total Games</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-3xl font-bold text-green-600">{stats.gamesEnabled.count}</div>
<div className="text-gray-600">Enabled Games</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-3xl font-bold text-blue-600">{stats.packs.count}</div>
<div className="text-gray-600">Total Packs</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="text-3xl font-bold text-purple-600">{stats.totalGamesPlayed.count}</div>
<div className="text-gray-600">Games Played</div>
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-4 mb-6">
<button
onClick={() => setShowAddGame(true)}
className="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition"
>
+ Add Game
</button>
<button
onClick={() => setShowImportExport(!showImportExport)}
className="bg-gray-600 text-white px-6 py-2 rounded-lg hover:bg-gray-700 transition"
>
Import/Export
</button>
<button
onClick={handleExportCSV}
className="bg-green-600 text-white px-6 py-2 rounded-lg hover:bg-green-700 transition"
>
Export CSV
</button>
</div>
{/* Import/Export Panel */}
{showImportExport && (
<ImportExportPanel onClose={() => setShowImportExport(false)} onImportComplete={loadData} />
)}
{/* Add/Edit Game Form */}
{(showAddGame || editingGame) && (
<GameForm
game={editingGame}
onClose={() => {
setShowAddGame(false);
setEditingGame(null);
}}
onSave={loadData}
/>
)}
{/* Pack Management */}
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<h2 className="text-2xl font-semibold mb-4 text-gray-800">Pack Management</h2>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
{packs.map(pack => (
<div key={pack.pack_name} className="border border-gray-300 rounded-lg p-4">
<h3 className="font-semibold text-lg text-gray-800 mb-2">{pack.pack_name}</h3>
<p className="text-gray-600 text-sm mb-3">
{pack.enabled_count} / {pack.game_count} games enabled
</p>
<div className="flex gap-2">
<button
onClick={() => handleTogglePack(pack.pack_name, true)}
className="flex-1 bg-green-600 text-white px-3 py-1 rounded text-sm hover:bg-green-700 transition"
>
Enable All
</button>
<button
onClick={() => handleTogglePack(pack.pack_name, false)}
className="flex-1 bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700 transition"
>
Disable All
</button>
</div>
</div>
))}
</div>
</div>
{/* Game List */}
<div className="bg-white rounded-lg shadow-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-semibold text-gray-800">Games</h2>
<select
value={selectedPack}
onChange={(e) => setSelectedPack(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="all">All Packs</option>
{packs.map(pack => (
<option key={pack.pack_name} value={pack.pack_name}>
{pack.pack_name}
</option>
))}
</select>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Status</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Title</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Pack</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Players</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Type</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Plays</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Score</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Actions</th>
</tr>
</thead>
<tbody>
{filteredGames.map(game => (
<tr key={game.id} className="border-b hover:bg-gray-50">
<td className="px-4 py-3">
<button
onClick={() => handleToggleGame(game.id)}
className={`px-3 py-1 rounded text-sm font-semibold ${
game.enabled
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{game.enabled ? 'Enabled' : 'Disabled'}
</button>
</td>
<td className="px-4 py-3 font-semibold text-gray-800">{game.title}</td>
<td className="px-4 py-3 text-gray-600 text-sm">{game.pack_name}</td>
<td className="px-4 py-3 text-gray-600">{game.min_players}-{game.max_players}</td>
<td className="px-4 py-3 text-gray-600 text-sm">{game.game_type || 'N/A'}</td>
<td className="px-4 py-3 text-gray-600">{game.play_count}</td>
<td className="px-4 py-3">
<span className={game.popularity_score >= 0 ? 'text-green-600' : 'text-red-600'}>
{game.popularity_score > 0 ? '+' : ''}{game.popularity_score}
</span>
</td>
<td className="px-4 py-3">
<div className="flex gap-2">
<button
onClick={() => setEditingGame(game)}
className="text-blue-600 hover:text-blue-800 text-sm"
>
Edit
</button>
<button
onClick={() => handleDeleteGame(game.id)}
className="text-red-600 hover:text-red-800 text-sm"
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
function GameForm({ game, onClose, onSave }) {
const [formData, setFormData] = useState(game || {
pack_name: '',
title: '',
min_players: 1,
max_players: 8,
length_minutes: '',
has_audience: false,
family_friendly: false,
game_type: '',
secondary_type: ''
});
const handleSubmit = async (e) => {
e.preventDefault();
try {
if (game) {
await api.put(`/games/${game.id}`, formData);
} else {
await api.post('/games', formData);
}
onSave();
onClose();
} catch (err) {
alert('Failed to save game');
}
};
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-2xl w-full max-h-[90vh] overflow-y-auto">
<h2 className="text-2xl font-bold mb-6">{game ? 'Edit Game' : 'Add New Game'}</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-gray-700 font-semibold mb-2">Title *</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({...formData, title: e.target.value})}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
required
/>
</div>
<div>
<label className="block text-gray-700 font-semibold mb-2">Pack Name *</label>
<input
type="text"
value={formData.pack_name}
onChange={(e) => setFormData({...formData, pack_name: e.target.value})}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
required
/>
</div>
<div>
<label className="block text-gray-700 font-semibold mb-2">Min Players *</label>
<input
type="number"
min="1"
value={formData.min_players}
onChange={(e) => setFormData({...formData, min_players: parseInt(e.target.value)})}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
required
/>
</div>
<div>
<label className="block text-gray-700 font-semibold mb-2">Max Players *</label>
<input
type="number"
min="1"
value={formData.max_players}
onChange={(e) => setFormData({...formData, max_players: parseInt(e.target.value)})}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
required
/>
</div>
<div>
<label className="block text-gray-700 font-semibold mb-2">Length (minutes)</label>
<input
type="number"
value={formData.length_minutes}
onChange={(e) => setFormData({...formData, length_minutes: e.target.value ? parseInt(e.target.value) : ''})}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-gray-700 font-semibold mb-2">Game Type</label>
<input
type="text"
value={formData.game_type}
onChange={(e) => setFormData({...formData, game_type: e.target.value})}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-gray-700 font-semibold mb-2">Secondary Type</label>
<input
type="text"
value={formData.secondary_type}
onChange={(e) => setFormData({...formData, secondary_type: e.target.value})}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
<div className="flex gap-4">
<label className="flex items-center">
<input
type="checkbox"
checked={formData.has_audience}
onChange={(e) => setFormData({...formData, has_audience: e.target.checked})}
className="mr-2"
/>
<span className="text-gray-700">Has Audience</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={formData.family_friendly}
onChange={(e) => setFormData({...formData, family_friendly: e.target.checked})}
className="mr-2"
/>
<span className="text-gray-700">Family Friendly</span>
</label>
</div>
<div className="flex gap-4 pt-4">
<button
type="submit"
className="flex-1 bg-indigo-600 text-white py-3 rounded-lg hover:bg-indigo-700 transition"
>
Save
</button>
<button
type="button"
onClick={onClose}
className="flex-1 bg-gray-600 text-white py-3 rounded-lg hover:bg-gray-700 transition"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
function ImportExportPanel({ onClose, onImportComplete }) {
const [csvData, setCsvData] = useState('');
const [importMode, setImportMode] = useState('append');
const handleImport = async () => {
if (!csvData.trim()) {
alert('Please enter CSV data');
return;
}
try {
await api.post('/games/import/csv', { csvData, mode: importMode });
alert('Import successful!');
onImportComplete();
onClose();
} catch (err) {
alert('Import failed: ' + (err.response?.data?.error || err.message));
}
};
return (
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<h3 className="text-xl font-semibold mb-4">Import Games from CSV</h3>
<div className="mb-4">
<label className="block text-gray-700 font-semibold mb-2">Import Mode</label>
<select
value={importMode}
onChange={(e) => setImportMode(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
>
<option value="append">Append (keep existing games)</option>
<option value="replace">Replace (delete all existing games)</option>
</select>
</div>
<div className="mb-4">
<label className="block text-gray-700 font-semibold mb-2">CSV Data</label>
<textarea
value={csvData}
onChange={(e) => setCsvData(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg h-48 font-mono text-sm"
placeholder="Paste CSV data here..."
/>
</div>
<div className="flex gap-4">
<button
onClick={handleImport}
className="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition"
>
Import
</button>
<button
onClick={onClose}
className="bg-gray-600 text-white px-6 py-2 rounded-lg hover:bg-gray-700 transition"
>
Close
</button>
</div>
</div>
);
}
export default Manager;

View File

@@ -0,0 +1,388 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../api/axios';
function Picker() {
const { isAuthenticated } = useAuth();
const navigate = useNavigate();
const [activeSession, setActiveSession] = useState(null);
const [allGames, setAllGames] = useState([]);
const [selectedGame, setSelectedGame] = useState(null);
const [loading, setLoading] = useState(true);
const [picking, setPicking] = useState(false);
const [error, setError] = useState('');
// Filters
const [playerCount, setPlayerCount] = useState('');
const [drawingFilter, setDrawingFilter] = useState('both');
const [lengthFilter, setLengthFilter] = useState('');
const [familyFriendlyFilter, setFamilyFriendlyFilter] = useState('');
// Manual game selection
const [showManualSelect, setShowManualSelect] = useState(false);
const [manualGameId, setManualGameId] = useState('');
useEffect(() => {
if (!isAuthenticated) {
navigate('/login');
return;
}
loadData();
}, [isAuthenticated, 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 newSession = await api.post('/sessions', {});
setActiveSession(newSession.data);
}
// Load all games for manual selection
const gamesResponse = await api.get('/games');
setAllGames(gamesResponse.data);
} catch (err) {
setError('Failed to load session data');
} finally {
setLoading(false);
}
};
const handlePickGame = async () => {
if (!activeSession) return;
setPicking(true);
setError('');
try {
const response = await api.post('/pick', {
sessionId: activeSession.id,
playerCount: playerCount ? parseInt(playerCount) : undefined,
drawing: drawingFilter !== 'both' ? drawingFilter : undefined,
length: lengthFilter || undefined,
familyFriendly: familyFriendlyFilter ? familyFriendlyFilter === 'yes' : undefined
});
setSelectedGame(response.data.game);
} catch (err) {
setError(err.response?.data?.error || 'Failed to pick a game');
setSelectedGame(null);
} finally {
setPicking(false);
}
};
const handleAcceptGame = async () => {
if (!selectedGame || !activeSession) return;
try {
await api.post(`/sessions/${activeSession.id}/games`, {
game_id: selectedGame.id,
manually_added: false
});
// Reload data
await loadData();
setSelectedGame(null);
setError('');
} catch (err) {
setError('Failed to add game to session');
}
};
const handleAddManualGame = async () => {
if (!manualGameId || !activeSession) return;
try {
await api.post(`/sessions/${activeSession.id}/games`, {
game_id: parseInt(manualGameId),
manually_added: true
});
// Reload data
await loadData();
setManualGameId('');
setShowManualSelect(false);
setError('');
} catch (err) {
setError('Failed to add game to session');
}
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="text-xl text-gray-600">Loading...</div>
</div>
);
}
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">
Failed to load or create session. Please try again.
</div>
</div>
);
}
return (
<div className="max-w-6xl mx-auto">
<h1 className="text-4xl font-bold mb-8 text-gray-800">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>
<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"
/>
</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"
>
<option value="both">Both</option>
<option value="only">Only Drawing</option>
<option value="exclude">No Drawing</option>
</select>
</div>
<div>
<label className="block text-gray-700 font-semibold mb-2">
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"
>
<option value="">Any</option>
<option value="short">Short (15 min)</option>
<option value="medium">Medium (16-25 min)</option>
<option value="long">Long (>25 min)</option>
</select>
</div>
<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>
</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>
{/* 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">
{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">
{selectedGame.title}
</h2>
<p className="text-xl text-gray-600 mb-4">{selectedGame.pack_name}</p>
<div className="grid grid-cols-2 gap-4 mb-6">
<div>
<span className="font-semibold text-gray-700">Players:</span>
<span className="ml-2 text-gray-600">
{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">
{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">
{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">
{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>
</div>
<div>
<span className="font-semibold text-gray-700">Popularity:</span>
<span className="ml-2 text-gray-600">
{selectedGame.popularity_score > 0 ? '+' : ''}
{selectedGame.popularity_score}
</span>
</div>
</div>
<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"
>
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"
>
🎲 Re-roll
</button>
</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">
Manual Game Selection
</h3>
<div className="flex 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"
>
<option value="">Select a game...</option>
{allGames.map((game) => (
<option key={game.id} value={game.id}>
{game.title} ({game.pack_name})
</option>
))}
</select>
<button
onClick={handleAddManualGame}
disabled={!manualGameId}
className="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition disabled:bg-gray-400"
>
Add
</button>
</div>
</div>
)}
{/* Session info and games */}
<SessionInfo sessionId={activeSession.id} />
</div>
</div>
</div>
);
}
function SessionInfo({ sessionId }) {
const [games, setGames] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadGames();
}, [sessionId]);
const loadGames = async () => {
try {
const response = await api.get(`/sessions/${sessionId}/games`);
setGames(response.data);
} catch (err) {
console.error('Failed to load session games');
} finally {
setLoading(false);
}
};
return (
<div className="bg-white rounded-lg shadow-lg p-6">
<h3 className="text-xl font-semibold mb-4 text-gray-800">
Games Played This Session ({games.length})
</h3>
{loading ? (
<p className="text-gray-500">Loading...</p>
) : games.length === 0 ? (
<p className="text-gray-500">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>
)}
</div>
<span className="text-sm text-gray-500">
{new Date(game.played_at).toLocaleTimeString()}
</span>
</div>
))}
</div>
)}
</div>
);
}
export default Picker;