initial commit
This commit is contained in:
76
frontend/src/App.jsx
Normal file
76
frontend/src/App.jsx
Normal 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
17
frontend/src/api/axios.js
Normal 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;
|
||||
|
||||
70
frontend/src/context/AuthContext.jsx
Normal file
70
frontend/src/context/AuthContext.jsx
Normal 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
18
frontend/src/index.css
Normal 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
17
frontend/src/main.jsx
Normal 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>,
|
||||
);
|
||||
|
||||
356
frontend/src/pages/History.jsx
Normal file
356
frontend/src/pages/History.jsx
Normal 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
155
frontend/src/pages/Home.jsx
Normal 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;
|
||||
|
||||
79
frontend/src/pages/Login.jsx
Normal file
79
frontend/src/pages/Login.jsx
Normal 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;
|
||||
|
||||
502
frontend/src/pages/Manager.jsx
Normal file
502
frontend/src/pages/Manager.jsx
Normal 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;
|
||||
|
||||
388
frontend/src/pages/Picker.jsx
Normal file
388
frontend/src/pages/Picker.jsx
Normal 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;
|
||||
|
||||
Reference in New Issue
Block a user