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

39
.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Dependencies
node_modules/
*/node_modules/
# Environment variables
.env
.env.local
# Database
*.db
*.sqlite
*.sqlite3
# Build outputs
frontend/dist/
frontend/build/
# Logs
*.log
npm-debug.log*
# OS files
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
*.swp
*.swo
# Docker
.dockerignore
# Cursor
.cursor/
chat-summaries/
plan.md

371
README.md Normal file
View File

@@ -0,0 +1,371 @@
# Jackbox Party Pack Game Picker
A full-stack web application that helps groups pick games to play from various Jackbox Party Packs. Features include random game selection with filters, session tracking, game management, and popularity scoring through chat log imports.
## Features
### Admin Features
- **Game Picker**: Randomly select games with intelligent filters
- Filter by player count, drawing games, game length, and family-friendly status
- Automatic repeat avoidance (prevents same game or alternating pattern)
- Manual game selection option
- Real-time session tracking
- **Game Manager**: Complete CRUD operations for games and packs
- Enable/disable individual games or entire packs
- Import/export games via CSV
- View statistics (play counts, popularity scores)
- Add, edit, and delete games
- **Session Management**: Track gaming sessions over time
- Create and close sessions
- View session history
- Import chat logs to calculate game popularity
- Track which games were played when
- **Chat Log Import**: Process chat messages to assess game popularity
- Supports "thisgame++" and "thisgame--" voting
- Automatically matches votes to games based on timestamps
- Updates popularity scores across sessions
### Public Features
- View active session and games currently being played
- Browse session history
- See game statistics and popularity
## Tech Stack
- **Frontend**: React 18 with Vite, Tailwind CSS, React Router
- **Backend**: Node.js with Express
- **Database**: SQLite with better-sqlite3
- **Authentication**: JWT-based admin authentication
- **Deployment**: Docker with docker-compose
## Prerequisites
- Docker and Docker Compose (recommended)
- OR Node.js 18+ and npm (for local development)
## Quick Start with Docker
1. **Clone the repository**
```bash
git clone <repository-url>
cd jackboxpartypack-gamepicker
```
2. **Create environment file**
Create a `.env` file in the root directory:
```env
PORT=5000
NODE_ENV=production
DB_PATH=/app/data/jackbox.db
JWT_SECRET=your-secret-jwt-key-change-this
ADMIN_KEY=your-admin-key-here
```
3. **Build and start the containers**
```bash
docker-compose up -d
```
4. **Access the application**
- Frontend: http://localhost:3000
- Backend API: http://localhost:5000
5. **Login as admin**
- Navigate to the login page
- Enter your `ADMIN_KEY` from the `.env` file
The database will be automatically initialized and populated with games from `games-list.csv` on first run.
## Local Development Setup
### Backend Setup
1. **Navigate to backend directory**
```bash
cd backend
```
2. **Install dependencies**
```bash
npm install
```
3. **Create environment file**
Create `backend/.env`:
```env
PORT=5000
DB_PATH=./data/jackbox.db
JWT_SECRET=your-secret-jwt-key
ADMIN_KEY=admin123
```
4. **Start the backend server**
```bash
npm run dev
```
The backend will run on http://localhost:5000
### Frontend Setup
1. **Navigate to frontend directory**
```bash
cd frontend
```
2. **Install dependencies**
```bash
npm install
```
3. **Start the development server**
```bash
npm run dev
```
The frontend will run on http://localhost:3000 and proxy API requests to the backend.
## Project Structure
```
/
├── backend/
│ ├── routes/ # API route handlers
│ │ ├── auth.js # Authentication endpoints
│ │ ├── games.js # Game CRUD and management
│ │ ├── sessions.js # Session management
│ │ ├── picker.js # Game picker algorithm
│ │ └── stats.js # Statistics endpoints
│ ├── middleware/ # Express middleware
│ │ └── auth.js # JWT authentication
│ ├── database.js # SQLite database setup
│ ├── bootstrap.js # Database initialization
│ ├── server.js # Express app entry point
│ ├── package.json
│ └── Dockerfile
├── frontend/
│ ├── src/
│ │ ├── pages/ # React page components
│ │ │ ├── Home.jsx
│ │ │ ├── Login.jsx
│ │ │ ├── Picker.jsx
│ │ │ ├── Manager.jsx
│ │ │ └── History.jsx
│ │ ├── context/ # React context providers
│ │ │ └── AuthContext.jsx
│ │ ├── api/ # API client
│ │ │ └── axios.js
│ │ ├── App.jsx
│ │ ├── main.jsx
│ │ └── index.css
│ ├── index.html
│ ├── vite.config.js
│ ├── tailwind.config.js
│ ├── package.json
│ ├── nginx.conf # Nginx config for Docker
│ └── Dockerfile
├── docker-compose.yml
├── games-list.csv # Initial game data
└── README.md
```
## API Endpoints
### Authentication
- `POST /api/auth/login` - Login with admin key
- `POST /api/auth/verify` - Verify JWT token
### Games
- `GET /api/games` - List all games (with filters)
- `GET /api/games/:id` - Get single game
- `POST /api/games` - Create game (admin)
- `PUT /api/games/:id` - Update game (admin)
- `DELETE /api/games/:id` - Delete game (admin)
- `PATCH /api/games/:id/toggle` - Toggle game enabled status (admin)
- `GET /api/games/meta/packs` - Get pack list with stats
- `PATCH /api/games/packs/:name/toggle` - Toggle entire pack (admin)
- `GET /api/games/export/csv` - Export games to CSV (admin)
- `POST /api/games/import/csv` - Import games from CSV (admin)
### Sessions
- `GET /api/sessions` - List all sessions
- `GET /api/sessions/active` - Get active session
- `GET /api/sessions/:id` - Get single session
- `POST /api/sessions` - Create new session (admin)
- `POST /api/sessions/:id/close` - Close session (admin)
- `GET /api/sessions/:id/games` - Get games in session
- `POST /api/sessions/:id/games` - Add game to session (admin)
- `POST /api/sessions/:id/chat-import` - Import chat log (admin)
### Game Picker
- `POST /api/pick` - Pick random game with filters
### Statistics
- `GET /api/stats` - Get overall statistics
## Usage Guide
### Starting a Game Session
1. Login as admin
2. Navigate to the Picker page
3. A new session will be created automatically if none exists
4. Set filters (player count, drawing preference, length, family-friendly)
5. Click "Roll the Dice" to get a random game
6. Review the suggested game and either:
- Click "Play This Game" to accept
- Click "Re-roll" to get a different game
7. The game is added to the session and play count is incremented
### Managing Games
1. Login as admin
2. Navigate to the Manager page
3. View statistics and pack information
4. Toggle individual games or entire packs on/off
5. Add new games with the "+ Add Game" button
6. Edit or delete existing games
7. Import/Export games via CSV
### Closing a Session and Importing Chat Logs
1. Navigate to the History page
2. Select the active session
3. Click "Import Chat Log"
4. Paste JSON array of chat messages with format:
```json
[
{
"username": "Alice",
"message": "thisgame++",
"timestamp": "2024-10-30T20:15:00Z"
},
{
"username": "Bob",
"message": "This is fun! thisgame++",
"timestamp": "2024-10-30T20:16:30Z"
}
]
```
5. The system will match votes to games based on timestamps
6. Click "Close Session" to finalize
7. Add optional notes about the session
## Chat Log Format
The chat import feature expects a JSON array where each message has:
- `username`: String - Name of the chatter
- `message`: String - The chat message (may contain "thisgame++" or "thisgame--")
- `timestamp`: String - ISO 8601 timestamp
The system will:
1. Parse each message for vote patterns
2. Match the timestamp to the game being played at that time
3. Update the game's popularity score (+1 for ++, -1 for --)
4. Store the chat log in the database
## Database Schema
### games
- id, pack_name, title, min_players, max_players, length_minutes
- has_audience, family_friendly, game_type, secondary_type
- play_count, popularity_score, enabled, created_at
### sessions
- id, created_at, closed_at, is_active, notes
### session_games
- id, session_id, game_id, played_at, manually_added
### chat_logs
- id, session_id, chatter_name, message, timestamp, parsed_vote
## Game Selection Algorithm
The picker uses the following logic:
1. **Filter eligible games**:
- Only enabled games
- Match player count range (if specified)
- Filter by drawing type (if specified)
- Filter by length range (if specified)
- Filter by family-friendly status (if specified)
2. **Apply repeat avoidance**:
- Get last 2 games from current session
- Exclude those games from selection pool
- This prevents immediate repeats and alternating patterns
3. **Random selection**:
- Pick a random game from remaining eligible games
- Return game details and pool size
## Docker Deployment
### Using Docker Compose
The provided `docker-compose.yml` sets up both frontend and backend services:
```bash
# Build and start
docker-compose up -d
# View logs
docker-compose logs -f
# Stop services
docker-compose down
# Rebuild after changes
docker-compose up -d --build
```
### Environment Variables
Set these in your `.env` file or docker-compose environment:
- `PORT` - Backend server port (default: 5000)
- `NODE_ENV` - Environment (production/development)
- `DB_PATH` - Path to SQLite database file
- `JWT_SECRET` - Secret key for JWT tokens
- `ADMIN_KEY` - Admin authentication key
### Data Persistence
The SQLite database is stored in `backend/data/` and is persisted via Docker volumes. To backup your data, copy the `backend/data/` directory.
## Troubleshooting
### Database not initializing
- Ensure `games-list.csv` is in the root directory
- Check backend logs: `docker-compose logs backend`
- Manually delete `backend/data/jackbox.db` to force re-initialization
### Can't login as admin
- Verify your `ADMIN_KEY` environment variable is set
- Check that the `.env` file is loaded correctly
- Try restarting the backend service
### Frontend can't reach backend
- Verify both containers are running: `docker-compose ps`
- Check network connectivity: `docker-compose logs frontend`
- Ensure nginx.conf proxy settings are correct
### Games not showing up
- Check if games are enabled in the Manager
- Verify filters aren't excluding all games
- Check database has games: View in Manager page
## License
MIT
## Contributing
Feel free to submit issues and pull requests!

0
backend/.gitkeep Normal file
View File

22
backend/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM node:18-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy application files
COPY . .
# Create data directory for SQLite database
RUN mkdir -p /app/data
# Expose port
EXPOSE 5000
# Start the application
CMD ["node", "server.js"]

73
backend/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,73 @@
const fs = require('fs');
const path = require('path');
const { parse } = require('csv-parse/sync');
const db = require('./database');
function bootstrapGames() {
// Check if games already exist
const count = db.prepare('SELECT COUNT(*) as count FROM games').get();
if (count.count > 0) {
console.log(`Database already has ${count.count} games. Skipping bootstrap.`);
return;
}
// Read the CSV file
const csvPath = path.join(__dirname, '..', 'games-list.csv');
if (!fs.existsSync(csvPath)) {
console.log('games-list.csv not found. Skipping bootstrap.');
return;
}
const csvContent = fs.readFileSync(csvPath, 'utf-8');
const records = parse(csvContent, {
columns: true,
skip_empty_lines: true,
trim: true
});
const insert = db.prepare(`
INSERT INTO games (
pack_name, title, min_players, max_players, length_minutes,
has_audience, family_friendly, game_type, secondary_type, enabled
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
`);
const insertMany = db.transaction((games) => {
for (const game of games) {
insert.run(
game['Game Pack'],
game['Game Title'],
parseInt(game['Min. Players']) || 1,
parseInt(game['Max. Players']) || 8,
parseLengthMinutes(game['Length']),
parseBoolean(game['Audience']),
parseBoolean(game['Family Friendly?']),
game['Game Type'] || null,
game['Secondary Type'] || null
);
}
});
insertMany(records);
console.log(`Successfully imported ${records.length} games from CSV`);
}
function parseLengthMinutes(lengthStr) {
if (!lengthStr || lengthStr === '????' || lengthStr === '?') {
return null;
}
const match = lengthStr.match(/(\d+)/);
return match ? parseInt(match[1]) : null;
}
function parseBoolean(value) {
if (!value || value === '?' || value === '????') {
return 0;
}
return value.toLowerCase() === 'yes' ? 1 : 0;
}
module.exports = { bootstrapGames };

83
backend/database.js Normal file
View File

@@ -0,0 +1,83 @@
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const dbPath = process.env.DB_PATH || path.join(__dirname, 'data', 'jackbox.db');
const dbDir = path.dirname(dbPath);
// Ensure data directory exists
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
const db = new Database(dbPath);
// Enable foreign keys
db.pragma('foreign_keys = ON');
// Create tables
function initializeDatabase() {
// Games table
db.exec(`
CREATE TABLE IF NOT EXISTS games (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pack_name TEXT NOT NULL,
title TEXT NOT NULL,
min_players INTEGER NOT NULL,
max_players INTEGER NOT NULL,
length_minutes INTEGER,
has_audience INTEGER DEFAULT 0,
family_friendly INTEGER DEFAULT 0,
game_type TEXT,
secondary_type TEXT,
play_count INTEGER DEFAULT 0,
popularity_score INTEGER DEFAULT 0,
enabled INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Sessions table
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
closed_at DATETIME,
is_active INTEGER DEFAULT 1,
notes TEXT
)
`);
// Session games table
db.exec(`
CREATE TABLE IF NOT EXISTS session_games (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
game_id INTEGER NOT NULL,
played_at DATETIME DEFAULT CURRENT_TIMESTAMP,
manually_added INTEGER DEFAULT 0,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
)
`);
// Chat logs table
db.exec(`
CREATE TABLE IF NOT EXISTS chat_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
chatter_name TEXT NOT NULL,
message TEXT NOT NULL,
timestamp DATETIME NOT NULL,
parsed_vote TEXT,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
)
`);
console.log('Database initialized successfully');
}
initializeDatabase();
module.exports = db;

View File

@@ -0,0 +1,23 @@
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
req.user = user;
next();
});
}
module.exports = { authenticateToken, JWT_SECRET };

26
backend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "jackbox-game-picker-backend",
"version": "1.0.0",
"description": "Backend API for Jackbox Party Pack Game Picker",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"keywords": [],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"better-sqlite3": "^9.2.2",
"jsonwebtoken": "^9.0.2",
"dotenv": "^16.3.1",
"csv-parse": "^5.5.3",
"csv-stringify": "^6.4.5"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}

44
backend/routes/auth.js Normal file
View File

@@ -0,0 +1,44 @@
const express = require('express');
const jwt = require('jsonwebtoken');
const { JWT_SECRET, authenticateToken } = require('../middleware/auth');
const router = express.Router();
const ADMIN_KEY = process.env.ADMIN_KEY || 'admin123';
// Login with admin key
router.post('/login', (req, res) => {
const { key } = req.body;
if (!key) {
return res.status(400).json({ error: 'Admin key is required' });
}
if (key !== ADMIN_KEY) {
return res.status(401).json({ error: 'Invalid admin key' });
}
// Generate JWT token
const token = jwt.sign(
{ role: 'admin', timestamp: Date.now() },
JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({
token,
message: 'Authentication successful',
expiresIn: '24h'
});
});
// Verify token validity
router.post('/verify', authenticateToken, (req, res) => {
res.json({
valid: true,
user: req.user
});
});
module.exports = router;

364
backend/routes/games.js Normal file
View File

@@ -0,0 +1,364 @@
const express = require('express');
const { authenticateToken } = require('../middleware/auth');
const db = require('../database');
const { stringify } = require('csv-stringify/sync');
const { parse } = require('csv-parse/sync');
const router = express.Router();
// Get all games with optional filters
router.get('/', (req, res) => {
try {
const {
enabled,
minPlayers,
maxPlayers,
playerCount,
drawing,
length,
familyFriendly,
pack
} = req.query;
let query = 'SELECT * FROM games WHERE 1=1';
const params = [];
if (enabled !== undefined) {
query += ' AND enabled = ?';
params.push(enabled === 'true' ? 1 : 0);
}
if (playerCount) {
const count = parseInt(playerCount);
query += ' AND min_players <= ? AND max_players >= ?';
params.push(count, count);
}
if (drawing === 'only') {
query += ' AND game_type = ?';
params.push('Drawing');
} else if (drawing === 'exclude') {
query += ' AND (game_type != ? OR game_type IS NULL)';
params.push('Drawing');
}
if (length) {
if (length === 'short') {
query += ' AND (length_minutes <= 15 OR length_minutes IS NULL)';
} else if (length === 'medium') {
query += ' AND length_minutes > 15 AND length_minutes <= 25';
} else if (length === 'long') {
query += ' AND length_minutes > 25';
}
}
if (familyFriendly !== undefined) {
query += ' AND family_friendly = ?';
params.push(familyFriendly === 'true' ? 1 : 0);
}
if (pack) {
query += ' AND pack_name = ?';
params.push(pack);
}
query += ' ORDER BY pack_name, title';
const stmt = db.prepare(query);
const games = stmt.all(...params);
res.json(games);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get single game by ID
router.get('/:id', (req, res) => {
try {
const game = db.prepare('SELECT * FROM games WHERE id = ?').get(req.params.id);
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
res.json(game);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Create new game (admin only)
router.post('/', authenticateToken, (req, res) => {
try {
const {
pack_name,
title,
min_players,
max_players,
length_minutes,
has_audience,
family_friendly,
game_type,
secondary_type
} = req.body;
if (!pack_name || !title || !min_players || !max_players) {
return res.status(400).json({ error: 'Missing required fields' });
}
const stmt = db.prepare(`
INSERT INTO games (
pack_name, title, min_players, max_players, length_minutes,
has_audience, family_friendly, game_type, secondary_type
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
pack_name,
title,
min_players,
max_players,
length_minutes || null,
has_audience ? 1 : 0,
family_friendly ? 1 : 0,
game_type || null,
secondary_type || null
);
const newGame = db.prepare('SELECT * FROM games WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(newGame);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Update game (admin only)
router.put('/:id', authenticateToken, (req, res) => {
try {
const {
pack_name,
title,
min_players,
max_players,
length_minutes,
has_audience,
family_friendly,
game_type,
secondary_type,
enabled
} = req.body;
const stmt = db.prepare(`
UPDATE games SET
pack_name = COALESCE(?, pack_name),
title = COALESCE(?, title),
min_players = COALESCE(?, min_players),
max_players = COALESCE(?, max_players),
length_minutes = ?,
has_audience = COALESCE(?, has_audience),
family_friendly = COALESCE(?, family_friendly),
game_type = ?,
secondary_type = ?,
enabled = COALESCE(?, enabled)
WHERE id = ?
`);
const result = stmt.run(
pack_name || null,
title || null,
min_players || null,
max_players || null,
length_minutes !== undefined ? length_minutes : null,
has_audience !== undefined ? (has_audience ? 1 : 0) : null,
family_friendly !== undefined ? (family_friendly ? 1 : 0) : null,
game_type !== undefined ? game_type : null,
secondary_type !== undefined ? secondary_type : null,
enabled !== undefined ? (enabled ? 1 : 0) : null,
req.params.id
);
if (result.changes === 0) {
return res.status(404).json({ error: 'Game not found' });
}
const updatedGame = db.prepare('SELECT * FROM games WHERE id = ?').get(req.params.id);
res.json(updatedGame);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Delete game (admin only)
router.delete('/:id', authenticateToken, (req, res) => {
try {
const stmt = db.prepare('DELETE FROM games WHERE id = ?');
const result = stmt.run(req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Game not found' });
}
res.json({ message: 'Game deleted successfully' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Toggle game enabled status (admin only)
router.patch('/:id/toggle', authenticateToken, (req, res) => {
try {
const game = db.prepare('SELECT enabled FROM games WHERE id = ?').get(req.params.id);
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
const newStatus = game.enabled === 1 ? 0 : 1;
db.prepare('UPDATE games SET enabled = ? WHERE id = ?').run(newStatus, req.params.id);
const updatedGame = db.prepare('SELECT * FROM games WHERE id = ?').get(req.params.id);
res.json(updatedGame);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get list of unique pack names
router.get('/meta/packs', (req, res) => {
try {
const packs = db.prepare(`
SELECT
pack_name,
COUNT(*) as game_count,
SUM(enabled) as enabled_count
FROM games
GROUP BY pack_name
ORDER BY pack_name
`).all();
res.json(packs);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Toggle entire pack (admin only)
router.patch('/packs/:name/toggle', authenticateToken, (req, res) => {
try {
const { enabled } = req.body;
if (enabled === undefined) {
return res.status(400).json({ error: 'enabled status required' });
}
const stmt = db.prepare('UPDATE games SET enabled = ? WHERE pack_name = ?');
const result = stmt.run(enabled ? 1 : 0, req.params.name);
res.json({
message: `Pack ${enabled ? 'enabled' : 'disabled'} successfully`,
gamesAffected: result.changes
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Export games to CSV (admin only)
router.get('/export/csv', authenticateToken, (req, res) => {
try {
const games = db.prepare('SELECT * FROM games ORDER BY pack_name, title').all();
const csvData = games.map(game => ({
'Game Pack': game.pack_name,
'Game Title': game.title,
'Min. Players': game.min_players,
'Max. Players': game.max_players,
'Length': game.length_minutes ? `${game.length_minutes} minutes` : '????',
'Audience': game.has_audience ? 'Yes' : 'No',
'Family Friendly?': game.family_friendly ? 'Yes' : 'No',
'Game Type': game.game_type || '',
'Secondary Type': game.secondary_type || ''
}));
const csv = stringify(csvData, { header: true });
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename=games-export.csv');
res.send(csv);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Import games from CSV (admin only)
router.post('/import/csv', authenticateToken, (req, res) => {
try {
const { csvData, mode } = req.body; // mode: 'append' or 'replace'
if (!csvData) {
return res.status(400).json({ error: 'CSV data required' });
}
const records = parse(csvData, {
columns: true,
skip_empty_lines: true,
trim: true
});
if (mode === 'replace') {
db.prepare('DELETE FROM games').run();
}
const insert = db.prepare(`
INSERT INTO games (
pack_name, title, min_players, max_players, length_minutes,
has_audience, family_friendly, game_type, secondary_type, enabled
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
`);
const insertMany = db.transaction((games) => {
for (const game of games) {
insert.run(
game['Game Pack'],
game['Game Title'],
parseInt(game['Min. Players']) || 1,
parseInt(game['Max. Players']) || 8,
parseLengthMinutes(game['Length']),
parseBoolean(game['Audience']),
parseBoolean(game['Family Friendly?']),
game['Game Type'] || null,
game['Secondary Type'] || null
);
}
});
insertMany(records);
res.json({
message: `Successfully imported ${records.length} games`,
count: records.length,
mode
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
function parseLengthMinutes(lengthStr) {
if (!lengthStr || lengthStr === '????' || lengthStr === '?') {
return null;
}
const match = lengthStr.match(/(\d+)/);
return match ? parseInt(match[1]) : null;
}
function parseBoolean(value) {
if (!value || value === '?' || value === '????') {
return 0;
}
return value.toLowerCase() === 'yes' ? 1 : 0;
}
module.exports = router;

100
backend/routes/picker.js Normal file
View File

@@ -0,0 +1,100 @@
const express = require('express');
const db = require('../database');
const router = express.Router();
// Pick a random game with filters and repeat avoidance
router.post('/pick', (req, res) => {
try {
const {
playerCount,
drawing,
length,
familyFriendly,
sessionId
} = req.body;
// Build query for eligible games
let query = 'SELECT * FROM games WHERE enabled = 1';
const params = [];
if (playerCount) {
const count = parseInt(playerCount);
query += ' AND min_players <= ? AND max_players >= ?';
params.push(count, count);
}
if (drawing === 'only') {
query += ' AND game_type = ?';
params.push('Drawing');
} else if (drawing === 'exclude') {
query += ' AND (game_type != ? OR game_type IS NULL)';
params.push('Drawing');
}
if (length) {
if (length === 'short') {
query += ' AND (length_minutes <= 15 OR length_minutes IS NULL)';
} else if (length === 'medium') {
query += ' AND length_minutes > 15 AND length_minutes <= 25';
} else if (length === 'long') {
query += ' AND length_minutes > 25';
}
}
if (familyFriendly !== undefined) {
query += ' AND family_friendly = ?';
params.push(familyFriendly ? 1 : 0);
}
// Get eligible games
const stmt = db.prepare(query);
let eligibleGames = stmt.all(...params);
if (eligibleGames.length === 0) {
return res.status(404).json({
error: 'No games match the current filters',
suggestion: 'Try adjusting your filters or enabling more games'
});
}
// Apply repeat avoidance if session provided
if (sessionId) {
const lastGames = db.prepare(`
SELECT game_id FROM session_games
WHERE session_id = ?
ORDER BY played_at DESC
LIMIT 2
`).all(sessionId);
const excludeIds = lastGames.map(g => g.game_id);
if (excludeIds.length > 0) {
eligibleGames = eligibleGames.filter(game => !excludeIds.includes(game.id));
}
if (eligibleGames.length === 0) {
return res.status(404).json({
error: 'All eligible games have been played recently',
suggestion: 'Enable more games or adjust your filters',
recentlyPlayed: excludeIds
});
}
}
// Pick random game from eligible pool
const randomIndex = Math.floor(Math.random() * eligibleGames.length);
const selectedGame = eligibleGames[randomIndex];
res.json({
game: selectedGame,
poolSize: eligibleGames.length,
totalEnabled: eligibleGames.length + (sessionId ? 2 : 0) // Approximate
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;

340
backend/routes/sessions.js Normal file
View File

@@ -0,0 +1,340 @@
const express = require('express');
const { authenticateToken } = require('../middleware/auth');
const db = require('../database');
const router = express.Router();
// Get all sessions
router.get('/', (req, res) => {
try {
const sessions = db.prepare(`
SELECT
s.*,
COUNT(sg.id) as games_played
FROM sessions s
LEFT JOIN session_games sg ON s.id = sg.session_id
GROUP BY s.id
ORDER BY s.created_at DESC
`).all();
res.json(sessions);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get active session
router.get('/active', (req, res) => {
try {
const session = db.prepare(`
SELECT
s.*,
COUNT(sg.id) as games_played
FROM sessions s
LEFT JOIN session_games sg ON s.id = sg.session_id
WHERE s.is_active = 1
GROUP BY s.id
LIMIT 1
`).get();
if (!session) {
return res.status(404).json({ error: 'No active session found' });
}
res.json(session);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get single session by ID
router.get('/:id', (req, res) => {
try {
const session = db.prepare(`
SELECT
s.*,
COUNT(sg.id) as games_played
FROM sessions s
LEFT JOIN session_games sg ON s.id = sg.session_id
WHERE s.id = ?
GROUP BY s.id
`).get(req.params.id);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
res.json(session);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Create new session (admin only)
router.post('/', authenticateToken, (req, res) => {
try {
const { notes } = req.body;
// Check if there's already an active session
const activeSession = db.prepare('SELECT id FROM sessions WHERE is_active = 1').get();
if (activeSession) {
return res.status(400).json({
error: 'An active session already exists. Please close it before creating a new one.',
activeSessionId: activeSession.id
});
}
const stmt = db.prepare(`
INSERT INTO sessions (notes, is_active)
VALUES (?, 1)
`);
const result = stmt.run(notes || null);
const newSession = db.prepare('SELECT * FROM sessions WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(newSession);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Close/finalize session (admin only)
router.post('/:id/close', authenticateToken, (req, res) => {
try {
const { notes } = req.body;
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(req.params.id);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
if (session.is_active === 0) {
return res.status(400).json({ error: 'Session is already closed' });
}
const stmt = db.prepare(`
UPDATE sessions
SET is_active = 0, closed_at = CURRENT_TIMESTAMP, notes = COALESCE(?, notes)
WHERE id = ?
`);
stmt.run(notes || null, req.params.id);
const closedSession = db.prepare('SELECT * FROM sessions WHERE id = ?').get(req.params.id);
res.json(closedSession);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get games played in a session
router.get('/:id/games', (req, res) => {
try {
const games = db.prepare(`
SELECT
sg.*,
g.pack_name,
g.title,
g.game_type,
g.min_players,
g.max_players,
g.popularity_score
FROM session_games sg
JOIN games g ON sg.game_id = g.id
WHERE sg.session_id = ?
ORDER BY sg.played_at ASC
`).all(req.params.id);
res.json(games);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Add game to session (admin only)
router.post('/:id/games', authenticateToken, (req, res) => {
try {
const { game_id, manually_added } = req.body;
if (!game_id) {
return res.status(400).json({ error: 'game_id is required' });
}
// Verify session exists and is active
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(req.params.id);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
if (session.is_active === 0) {
return res.status(400).json({ error: 'Cannot add games to a closed session' });
}
// Verify game exists
const game = db.prepare('SELECT * FROM games WHERE id = ?').get(game_id);
if (!game) {
return res.status(404).json({ error: 'Game not found' });
}
// Add game to session
const stmt = db.prepare(`
INSERT INTO session_games (session_id, game_id, manually_added)
VALUES (?, ?, ?)
`);
const result = stmt.run(req.params.id, game_id, manually_added ? 1 : 0);
// Increment play count for the game
db.prepare('UPDATE games SET play_count = play_count + 1 WHERE id = ?').run(game_id);
const sessionGame = db.prepare(`
SELECT
sg.*,
g.pack_name,
g.title,
g.game_type
FROM session_games sg
JOIN games g ON sg.game_id = g.id
WHERE sg.id = ?
`).get(result.lastInsertRowid);
res.status(201).json(sessionGame);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Import chat log and process popularity (admin only)
router.post('/:id/chat-import', authenticateToken, (req, res) => {
try {
const { chatData } = req.body;
if (!chatData || !Array.isArray(chatData)) {
return res.status(400).json({ error: 'chatData must be an array' });
}
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(req.params.id);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
// Get all games played in this session with timestamps
const sessionGames = db.prepare(`
SELECT sg.game_id, sg.played_at, g.title
FROM session_games sg
JOIN games g ON sg.game_id = g.id
WHERE sg.session_id = ?
ORDER BY sg.played_at ASC
`).all(req.params.id);
if (sessionGames.length === 0) {
return res.status(400).json({ error: 'No games played in this session to match votes against' });
}
let votesProcessed = 0;
const votesByGame = {};
const insertChatLog = db.prepare(`
INSERT INTO chat_logs (session_id, chatter_name, message, timestamp, parsed_vote)
VALUES (?, ?, ?, ?, ?)
`);
const updatePopularity = db.prepare(`
UPDATE games SET popularity_score = popularity_score + ? WHERE id = ?
`);
const processVotes = db.transaction((messages) => {
for (const msg of messages) {
const { username, message, timestamp } = msg;
if (!username || !message || !timestamp) {
continue;
}
// Check for vote patterns
let vote = null;
if (message.includes('thisgame++')) {
vote = 'thisgame++';
} else if (message.includes('thisgame--')) {
vote = 'thisgame--';
}
// Insert into chat logs
insertChatLog.run(
req.params.id,
username,
message,
timestamp,
vote
);
if (vote) {
// Find which game was being played at this timestamp
const messageTime = new Date(timestamp).getTime();
let matchedGame = null;
for (let i = 0; i < sessionGames.length; i++) {
const currentGame = sessionGames[i];
const nextGame = sessionGames[i + 1];
const currentGameTime = new Date(currentGame.played_at).getTime();
if (nextGame) {
const nextGameTime = new Date(nextGame.played_at).getTime();
if (messageTime >= currentGameTime && messageTime < nextGameTime) {
matchedGame = currentGame;
break;
}
} else {
// Last game in session
if (messageTime >= currentGameTime) {
matchedGame = currentGame;
break;
}
}
}
if (matchedGame) {
const points = vote === 'thisgame++' ? 1 : -1;
updatePopularity.run(points, matchedGame.game_id);
if (!votesByGame[matchedGame.game_id]) {
votesByGame[matchedGame.game_id] = {
title: matchedGame.title,
upvotes: 0,
downvotes: 0
};
}
if (points > 0) {
votesByGame[matchedGame.game_id].upvotes++;
} else {
votesByGame[matchedGame.game_id].downvotes++;
}
votesProcessed++;
}
}
}
});
processVotes(chatData);
res.json({
message: 'Chat log imported and processed successfully',
messagesImported: chatData.length,
votesProcessed,
votesByGame
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;

39
backend/routes/stats.js Normal file
View File

@@ -0,0 +1,39 @@
const express = require('express');
const db = require('../database');
const router = express.Router();
// Get overall statistics
router.get('/', (req, res) => {
try {
const stats = {
games: db.prepare('SELECT COUNT(*) as count FROM games').get(),
gamesEnabled: db.prepare('SELECT COUNT(*) as count FROM games WHERE enabled = 1').get(),
packs: db.prepare('SELECT COUNT(DISTINCT pack_name) as count FROM games').get(),
sessions: db.prepare('SELECT COUNT(*) as count FROM sessions').get(),
activeSessions: db.prepare('SELECT COUNT(*) as count FROM sessions WHERE is_active = 1').get(),
totalGamesPlayed: db.prepare('SELECT COUNT(*) as count FROM session_games').get(),
mostPlayedGames: db.prepare(`
SELECT g.id, g.title, g.pack_name, g.play_count, g.popularity_score
FROM games g
WHERE g.play_count > 0
ORDER BY g.play_count DESC
LIMIT 10
`).all(),
topRatedGames: db.prepare(`
SELECT g.id, g.title, g.pack_name, g.play_count, g.popularity_score
FROM games g
WHERE g.popularity_score > 0
ORDER BY g.popularity_score DESC
LIMIT 10
`).all()
};
res.json(stats);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;

43
backend/server.js Normal file
View File

@@ -0,0 +1,43 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const { bootstrapGames } = require('./bootstrap');
const app = express();
const PORT = process.env.PORT || 5000;
// Middleware
app.use(cors());
app.use(express.json());
// Bootstrap database with games
bootstrapGames();
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', message: 'Jackbox Game Picker API is running' });
});
// Routes
const authRoutes = require('./routes/auth');
const gamesRoutes = require('./routes/games');
const sessionsRoutes = require('./routes/sessions');
const statsRoutes = require('./routes/stats');
const pickerRoutes = require('./routes/picker');
app.use('/api/auth', authRoutes);
app.use('/api/games', gamesRoutes);
app.use('/api/sessions', sessionsRoutes);
app.use('/api/stats', statsRoutes);
app.use('/api', pickerRoutes);
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Something went wrong!', message: err.message });
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Server is running on port ${PORT}`);
});

43
docker-compose.yml Normal file
View File

@@ -0,0 +1,43 @@
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: jackbox-backend
restart: unless-stopped
environment:
- PORT=5000
- NODE_ENV=production
- DB_PATH=/app/data/jackbox.db
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- ADMIN_KEY=${ADMIN_KEY:-admin123}
volumes:
- ./backend/data:/app/data
- ./games-list.csv:/app/games-list.csv:ro
ports:
- "5000:5000"
networks:
- jackbox-network
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: jackbox-frontend
restart: unless-stopped
ports:
- "3000:80"
depends_on:
- backend
networks:
- jackbox-network
networks:
jackbox-network:
driver: bridge
volumes:
backend-data:

0
frontend/.gitkeep Normal file
View File

30
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy application files
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built files from builder
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

14
frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Jackbox Game Picker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

32
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,32 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# API proxy
location /api {
proxy_pass http://backend:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# React routing
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

27
frontend/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "jackbox-game-picker-frontend",
"version": "1.0.0",
"description": "Frontend for Jackbox Party Pack Game Picker",
"private": true,
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.1",
"axios": "^1.6.2"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"vite": "^5.0.8"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
}
}

View File

@@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

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;

View File

@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

17
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': {
target: 'http://backend:5000',
changeOrigin: true,
},
},
},
});

60
games-list.csv Normal file
View File

@@ -0,0 +1,60 @@
Game Pack,Game Title,Min. Players,Max. Players,Length,Audience,Family Friendly?,Game Type,Secondary Type
Jackbox Party Pack 1,Drawful,3,8,15 minutes,No,No,Drawing,
Jackbox Party Pack 1,Fibbage XL,2,8,15 minutes,No,No,Fill-In-The-Blank,Trivia
Jackbox Party Pack 1,Lie Swatter,1,100,????,No,No,Trivia,
Jackbox Party Pack 1,Word Spud,2,8,????,No,No,Writing,
Jackbox Party Pack 1,You Don't Know Jack® 2015,1,4,20 minutes,No,No,Trivia,
Jackbox Party Pack 2,Bidiots,3,6,15 minutes,No,Yes,Drawing,
Jackbox Party Pack 2,Bomb Corp.,1,4,15 minutes,No,Yes,Teamwork,
Jackbox Party Pack 2,Earwax™,3,8,15 minutes,Yes,Yes,Audio,
Jackbox Party Pack 2,Fibbage 2,2,8,15 minutes,Yes,Yes,Fill-In-The-Blank,Trivia
Jackbox Party Pack 2,Quiplash XL,3,8,15 minutes,Yes,Yes,Fill-In-The-Blank,
Jackbox Party Pack 3,Fakin' It!,3,6,15 minutes,Yes,Yes,Hidden Identity,Other
Jackbox Party Pack 3,Guesspionage,2,8,15 minutes,Yes,Yes,Trivia,
Jackbox Party Pack 3,Quiplash 2,3,8,15 minutes,Yes,Yes,Fill-In-The-Blank,
Jackbox Party Pack 3,Tee K.O. T-Shirt Knock Out,3,8,30 minutes,Yes,Yes,Drawing,
Jackbox Party Pack 3,Trivia Murder Party,1,8,15 minutes,Yes,No,Trivia,
Jackbox Party Pack 4,Bracketeering,3,16,15 minutes,Yes,Yes,Fill-In-The-Blank,
Jackbox Party Pack 4,Civic Doodle,3,8,20 minutes,Yes,Yes,Drawing,
Jackbox Party Pack 4,Fibbage 3,2,8,15 minutes,Yes,Yes,Fill-In-The-Blank,Trivia
Jackbox Party Pack 4,Monster Seeking Monster,3,7,15 minutes,Yes,No,Roleplay,Hidden Identity
Jackbox Party Pack 4,Survive the Internet,3,8,20 minutes,Yes,Yes,Fill-In-The-Blank,
Jackbox Party Pack 5,Mad Verse City,3,8,15 minutes,Yes,Yes,Fill-In-The-Blank,
Jackbox Party Pack 5,Patently Stupid,3,8,20 minutes,Yes,Yes,Drawing,
Jackbox Party Pack 5,Split the Room,3,8,15 minutes,Yes,Yes,Fill-In-The-Blank,
Jackbox Party Pack 5,You Don't Know Jack® Full Stream,1,8,15 minutes,Yes,No,Trivia,
Jackbox Party Pack 5,Zeeple Dome,1,6,10 minutes,No,Yes,Teamwork,
Jackbox Party Pack 6,Dictionarium,3,8,10 minutes,Yes,Yes,Writing,Fill-In-The-Blank
Jackbox Party Pack 6,Joke Boat,3,8,15 minutes,Yes,Yes,Fill-In-The-Blank,
Jackbox Party Pack 6,Push the Button,4,10,20 minutes,No,Yes,Hidden Identity,Teamwork
Jackbox Party Pack 6,Role Models,3,6,15 minutes,Yes,Yes,Other,
Jackbox Party Pack 6,Trivia Murder Party 2,1,8,15 minutes,Yes,No,Trivia,
Jackbox Party Pack 7,Blather 'Round,2,6,15 minutes,Yes,Yes,Fill-In-The-Blank,Other
Jackbox Party Pack 7,Champ'd Up,3,8,20 minutes,Yes,Yes,Drawing,
Jackbox Party Pack 7,Quiplash 3,3,8,15 minutes,Yes,Yes,Fill-In-The-Blank,
Jackbox Party Pack 7,Talking Points,3,8,20 minutes,Yes,Yes,Other,
Jackbox Party Pack 7,The Devils and the Details,3,8,15 minutes,Yes,No,Teamwork,
Jackbox Party Pack 8,Drawful Animate,3,10,15 minutes,Yes,Yes,Drawing,
Jackbox Party Pack 8,Job Job,3,10,20 minutes,Yes,Yes,Fill-In-The-Blank,
Jackbox Party Pack 8,The Poll Mine,2,10,15 minutes,Yes,Yes,Teamwork,Out Loud Play
Jackbox Party Pack 8,The Wheel of Enormous Proportions,2,8,15 minutes,Yes,Yes,Trivia,
Jackbox Party Pack 8,Weapons Drawn,4,8,20 minutes,Yes,No,Drawing,Hidden Identity
Jackbox Party Pack 9,Fibbage 4,2,8,20 minutes,Yes,Yes,Fill-In-The-Blank,Trivia
Jackbox Party Pack 9,Junktopia,3,8,20 minutes,Yes,Yes,Writing,Out Loud Play
Jackbox Party Pack 9,Nonsensory,3,8,25 minutes,Yes,Yes,Writing,Drawing
Jackbox Party Pack 9,Quixort,1,10,20 minutes,Yes,Yes,Trivia,Teamwork
Jackbox Party Pack 9,Roomerang,4,9,30 minutes,Yes,Yes,Fill-In-The-Blank,Out Loud Play
Jackbox Party Pack 10,Dodo Re Mi,1,9,5 minutes,Yes,Yes,Music,Teamwork
Jackbox Party Pack 10,Fixy Text,3,8,15 minutes,Yes,Yes,Writing,Teamwork
Jackbox Party Pack 10,Hypnotorious,4,8,20 minutes,Yes,Yes,Hidden Identity,Roleplay
Jackbox Party Pack 10,Tee K.O. 2,3,8,20 minutes,Yes,Yes,Drawing,
Jackbox Party Pack 10,Time Jinx,1,8,20 minutes,Yes,Yes,Trivia,
Jackbox Party Pack 11,Suspectives,4,8,????,?,?,?,?
Jackbox Party Pack 11,Doominate,3,8,????,?,?,?,?
Jackbox Party Pack 11,Legends of Trivia,1,6,????,?,?,?,?
Jackbox Party Pack 11,Hear Say,2,8,????,?,?,?,?
Jackbox Party Pack 11,Cookie Haus,3,8,????,?,?,?,?
Jackbox Naughty Pack,Fakin It All Night Long,3,8,????,?,No,?,?
Jackbox Naughty Pack,Dirty Drawful,3,8,????,?,No,?,?
Jackbox Naughty Pack,Let Me Finish,3,8,????,?,No,?,?
Jackbox Party Pack,Survey Scramble,2,10,????,Yes,Yes,?,?
1 Game Pack Game Title Min. Players Max. Players Length Audience Family Friendly? Game Type Secondary Type
2 Jackbox Party Pack 1 Drawful 3 8 15 minutes No No Drawing
3 Jackbox Party Pack 1 Fibbage XL 2 8 15 minutes No No Fill-In-The-Blank Trivia
4 Jackbox Party Pack 1 Lie Swatter 1 100 ???? No No Trivia
5 Jackbox Party Pack 1 Word Spud 2 8 ???? No No Writing
6 Jackbox Party Pack 1 You Don't Know Jack® 2015 1 4 20 minutes No No Trivia
7 Jackbox Party Pack 2 Bidiots 3 6 15 minutes No Yes Drawing
8 Jackbox Party Pack 2 Bomb Corp. 1 4 15 minutes No Yes Teamwork
9 Jackbox Party Pack 2 Earwax™ 3 8 15 minutes Yes Yes Audio
10 Jackbox Party Pack 2 Fibbage 2 2 8 15 minutes Yes Yes Fill-In-The-Blank Trivia
11 Jackbox Party Pack 2 Quiplash XL 3 8 15 minutes Yes Yes Fill-In-The-Blank
12 Jackbox Party Pack 3 Fakin' It! 3 6 15 minutes Yes Yes Hidden Identity Other
13 Jackbox Party Pack 3 Guesspionage 2 8 15 minutes Yes Yes Trivia
14 Jackbox Party Pack 3 Quiplash 2 3 8 15 minutes Yes Yes Fill-In-The-Blank
15 Jackbox Party Pack 3 Tee K.O. T-Shirt Knock Out 3 8 30 minutes Yes Yes Drawing
16 Jackbox Party Pack 3 Trivia Murder Party 1 8 15 minutes Yes No Trivia
17 Jackbox Party Pack 4 Bracketeering 3 16 15 minutes Yes Yes Fill-In-The-Blank
18 Jackbox Party Pack 4 Civic Doodle 3 8 20 minutes Yes Yes Drawing
19 Jackbox Party Pack 4 Fibbage 3 2 8 15 minutes Yes Yes Fill-In-The-Blank Trivia
20 Jackbox Party Pack 4 Monster Seeking Monster 3 7 15 minutes Yes No Roleplay Hidden Identity
21 Jackbox Party Pack 4 Survive the Internet 3 8 20 minutes Yes Yes Fill-In-The-Blank
22 Jackbox Party Pack 5 Mad Verse City 3 8 15 minutes Yes Yes Fill-In-The-Blank
23 Jackbox Party Pack 5 Patently Stupid 3 8 20 minutes Yes Yes Drawing
24 Jackbox Party Pack 5 Split the Room 3 8 15 minutes Yes Yes Fill-In-The-Blank
25 Jackbox Party Pack 5 You Don't Know Jack® Full Stream 1 8 15 minutes Yes No Trivia
26 Jackbox Party Pack 5 Zeeple Dome 1 6 10 minutes No Yes Teamwork
27 Jackbox Party Pack 6 Dictionarium 3 8 10 minutes Yes Yes Writing Fill-In-The-Blank
28 Jackbox Party Pack 6 Joke Boat 3 8 15 minutes Yes Yes Fill-In-The-Blank
29 Jackbox Party Pack 6 Push the Button 4 10 20 minutes No Yes Hidden Identity Teamwork
30 Jackbox Party Pack 6 Role Models 3 6 15 minutes Yes Yes Other
31 Jackbox Party Pack 6 Trivia Murder Party 2 1 8 15 minutes Yes No Trivia
32 Jackbox Party Pack 7 Blather 'Round 2 6 15 minutes Yes Yes Fill-In-The-Blank Other
33 Jackbox Party Pack 7 Champ'd Up 3 8 20 minutes Yes Yes Drawing
34 Jackbox Party Pack 7 Quiplash 3 3 8 15 minutes Yes Yes Fill-In-The-Blank
35 Jackbox Party Pack 7 Talking Points 3 8 20 minutes Yes Yes Other
36 Jackbox Party Pack 7 The Devils and the Details 3 8 15 minutes Yes No Teamwork
37 Jackbox Party Pack 8 Drawful Animate 3 10 15 minutes Yes Yes Drawing
38 Jackbox Party Pack 8 Job Job 3 10 20 minutes Yes Yes Fill-In-The-Blank
39 Jackbox Party Pack 8 The Poll Mine 2 10 15 minutes Yes Yes Teamwork Out Loud Play
40 Jackbox Party Pack 8 The Wheel of Enormous Proportions 2 8 15 minutes Yes Yes Trivia
41 Jackbox Party Pack 8 Weapons Drawn 4 8 20 minutes Yes No Drawing Hidden Identity
42 Jackbox Party Pack 9 Fibbage 4 2 8 20 minutes Yes Yes Fill-In-The-Blank Trivia
43 Jackbox Party Pack 9 Junktopia 3 8 20 minutes Yes Yes Writing Out Loud Play
44 Jackbox Party Pack 9 Nonsensory 3 8 25 minutes Yes Yes Writing Drawing
45 Jackbox Party Pack 9 Quixort 1 10 20 minutes Yes Yes Trivia Teamwork
46 Jackbox Party Pack 9 Roomerang 4 9 30 minutes Yes Yes Fill-In-The-Blank Out Loud Play
47 Jackbox Party Pack 10 Dodo Re Mi 1 9 5 minutes Yes Yes Music Teamwork
48 Jackbox Party Pack 10 Fixy Text 3 8 15 minutes Yes Yes Writing Teamwork
49 Jackbox Party Pack 10 Hypnotorious 4 8 20 minutes Yes Yes Hidden Identity Roleplay
50 Jackbox Party Pack 10 Tee K.O. 2 3 8 20 minutes Yes Yes Drawing
51 Jackbox Party Pack 10 Time Jinx 1 8 20 minutes Yes Yes Trivia
52 Jackbox Party Pack 11 Suspectives 4 8 ???? ? ? ? ?
53 Jackbox Party Pack 11 Doominate 3 8 ???? ? ? ? ?
54 Jackbox Party Pack 11 Legends of Trivia 1 6 ???? ? ? ? ?
55 Jackbox Party Pack 11 Hear Say 2 8 ???? ? ? ? ?
56 Jackbox Party Pack 11 Cookie Haus 3 8 ???? ? ? ? ?
57 Jackbox Naughty Pack Fakin’ It All Night Long 3 8 ???? ? No ? ?
58 Jackbox Naughty Pack Dirty Drawful 3 8 ???? ? No ? ?
59 Jackbox Naughty Pack Let Me Finish 3 8 ???? ? No ? ?
60 Jackbox Party Pack Survey Scramble 2 10 ???? Yes Yes ? ?