initial commit
This commit is contained in:
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal 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
371
README.md
Normal 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
0
backend/.gitkeep
Normal file
22
backend/Dockerfile
Normal file
22
backend/Dockerfile
Normal 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
73
backend/bootstrap.js
vendored
Normal 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
83
backend/database.js
Normal 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;
|
||||
|
||||
23
backend/middleware/auth.js
Normal file
23
backend/middleware/auth.js
Normal 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
26
backend/package.json
Normal 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
44
backend/routes/auth.js
Normal 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
364
backend/routes/games.js
Normal 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
100
backend/routes/picker.js
Normal 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
340
backend/routes/sessions.js
Normal 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
39
backend/routes/stats.js
Normal 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
43
backend/server.js
Normal 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
43
docker-compose.yml
Normal 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
0
frontend/.gitkeep
Normal file
30
frontend/Dockerfile
Normal file
30
frontend/Dockerfile
Normal 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
14
frontend/index.html
Normal 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
32
frontend/nginx.conf
Normal 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
27
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
||||
7
frontend/postcss.config.js
Normal file
7
frontend/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
76
frontend/src/App.jsx
Normal file
76
frontend/src/App.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, Link } from 'react-router-dom';
|
||||
import { useAuth } from './context/AuthContext';
|
||||
import Home from './pages/Home';
|
||||
import Login from './pages/Login';
|
||||
import Picker from './pages/Picker';
|
||||
import Manager from './pages/Manager';
|
||||
import History from './pages/History';
|
||||
|
||||
function App() {
|
||||
const { isAuthenticated, logout } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
{/* Navigation */}
|
||||
<nav className="bg-indigo-600 text-white shadow-lg">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex justify-between items-center py-4">
|
||||
<Link to="/" className="text-2xl font-bold">
|
||||
Jackbox Game Picker
|
||||
</Link>
|
||||
|
||||
<div className="flex gap-4 items-center">
|
||||
<Link to="/" className="hover:text-indigo-200 transition">
|
||||
Home
|
||||
</Link>
|
||||
<Link to="/history" className="hover:text-indigo-200 transition">
|
||||
History
|
||||
</Link>
|
||||
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<Link to="/picker" className="hover:text-indigo-200 transition">
|
||||
Picker
|
||||
</Link>
|
||||
<Link to="/manager" className="hover:text-indigo-200 transition">
|
||||
Manager
|
||||
</Link>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="bg-indigo-700 hover:bg-indigo-800 px-4 py-2 rounded transition"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isAuthenticated && (
|
||||
<Link
|
||||
to="/login"
|
||||
className="bg-indigo-700 hover:bg-indigo-800 px-4 py-2 rounded transition"
|
||||
>
|
||||
Admin Login
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/picker" element={<Picker />} />
|
||||
<Route path="/manager" element={<Manager />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
17
frontend/src/api/axios.js
Normal file
17
frontend/src/api/axios.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
});
|
||||
|
||||
// Add token to requests if available
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
export default api;
|
||||
|
||||
70
frontend/src/context/AuthContext.jsx
Normal file
70
frontend/src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { createContext, useState, useContext, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [token, setToken] = useState(localStorage.getItem('adminToken'));
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const verifyToken = async () => {
|
||||
if (token) {
|
||||
try {
|
||||
await axios.post('/api/auth/verify', {}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setIsAuthenticated(true);
|
||||
} catch (error) {
|
||||
console.error('Token verification failed:', error);
|
||||
logout();
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
verifyToken();
|
||||
}, [token]);
|
||||
|
||||
const login = async (key) => {
|
||||
try {
|
||||
const response = await axios.post('/api/auth/login', { key });
|
||||
const newToken = response.data.token;
|
||||
localStorage.setItem('adminToken', newToken);
|
||||
setToken(newToken);
|
||||
setIsAuthenticated(true);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.error || 'Login failed'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('adminToken');
|
||||
setToken(null);
|
||||
setIsAuthenticated(false);
|
||||
};
|
||||
|
||||
const value = {
|
||||
token,
|
||||
isAuthenticated,
|
||||
loading,
|
||||
login,
|
||||
logout
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
18
frontend/src/index.css
Normal file
18
frontend/src/index.css
Normal file
@@ -0,0 +1,18 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
17
frontend/src/main.jsx
Normal file
17
frontend/src/main.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
356
frontend/src/pages/History.jsx
Normal file
356
frontend/src/pages/History.jsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import api from '../api/axios';
|
||||
|
||||
function History() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [sessions, setSessions] = useState([]);
|
||||
const [selectedSession, setSelectedSession] = useState(null);
|
||||
const [sessionGames, setSessionGames] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showChatImport, setShowChatImport] = useState(false);
|
||||
const [closingSession, setClosingSession] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, []);
|
||||
|
||||
const loadSessions = async () => {
|
||||
try {
|
||||
const response = await api.get('/sessions');
|
||||
setSessions(response.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load sessions', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSessionGames = async (sessionId) => {
|
||||
try {
|
||||
const response = await api.get(`/sessions/${sessionId}/games`);
|
||||
setSessionGames(response.data);
|
||||
setSelectedSession(sessionId);
|
||||
} catch (err) {
|
||||
console.error('Failed to load session games', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseSession = async (sessionId, notes) => {
|
||||
try {
|
||||
await api.post(`/sessions/${sessionId}/close`, { notes });
|
||||
await loadSessions();
|
||||
setClosingSession(null);
|
||||
if (selectedSession === sessionId) {
|
||||
setSelectedSession(null);
|
||||
setSessionGames([]);
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Failed to close session');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="text-xl text-gray-600">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-4xl font-bold mb-8 text-gray-800">Session History</h1>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{/* Sessions List */}
|
||||
<div className="md:col-span-1">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-800">Sessions</h2>
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
<p className="text-gray-500">No sessions found</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[600px] overflow-y-auto">
|
||||
{sessions.map(session => (
|
||||
<div
|
||||
key={session.id}
|
||||
onClick={() => loadSessionGames(session.id)}
|
||||
className={`p-4 border rounded-lg cursor-pointer transition ${
|
||||
selectedSession === session.id
|
||||
? 'border-indigo-500 bg-indigo-50'
|
||||
: 'border-gray-300 hover:border-indigo-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="font-semibold text-gray-800">
|
||||
Session #{session.id}
|
||||
</div>
|
||||
{session.is_active === 1 && (
|
||||
<span className="bg-green-100 text-green-800 text-xs px-2 py-1 rounded">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{new Date(session.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{session.games_played} game{session.games_played !== 1 ? 's' : ''} played
|
||||
</div>
|
||||
|
||||
{isAuthenticated && session.is_active === 1 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setClosingSession(session.id);
|
||||
}}
|
||||
className="mt-2 w-full bg-yellow-600 text-white px-3 py-1 rounded text-sm hover:bg-yellow-700 transition"
|
||||
>
|
||||
Close Session
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Details */}
|
||||
<div className="md:col-span-2">
|
||||
{selectedSession ? (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-800">
|
||||
Session #{selectedSession}
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
{sessions.find(s => s.id === selectedSession)?.created_at &&
|
||||
new Date(sessions.find(s => s.id === selectedSession).created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isAuthenticated && sessions.find(s => s.id === selectedSession)?.is_active === 1 && (
|
||||
<button
|
||||
onClick={() => setShowChatImport(true)}
|
||||
className="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition"
|
||||
>
|
||||
Import Chat Log
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showChatImport && (
|
||||
<ChatImportPanel
|
||||
sessionId={selectedSession}
|
||||
onClose={() => setShowChatImport(false)}
|
||||
onImportComplete={() => {
|
||||
loadSessionGames(selectedSession);
|
||||
setShowChatImport(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sessionGames.length === 0 ? (
|
||||
<p className="text-gray-500">No games played in this session</p>
|
||||
) : (
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-4 text-gray-700">
|
||||
Games Played ({sessionGames.length})
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{sessionGames.map((game, index) => (
|
||||
<div key={game.id} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<div className="font-semibold text-lg text-gray-800">
|
||||
{index + 1}. {game.title}
|
||||
</div>
|
||||
<div className="text-gray-600">{game.pack_name}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date(game.played_at).toLocaleTimeString()}
|
||||
</div>
|
||||
{game.manually_added === 1 && (
|
||||
<span className="inline-block mt-1 text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
|
||||
Manual
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm text-gray-600">
|
||||
<div>
|
||||
<span className="font-semibold">Players:</span> {game.min_players}-{game.max_players}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Type:</span> {game.game_type || 'N/A'}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Popularity:</span>{' '}
|
||||
<span className={game.popularity_score >= 0 ? 'text-green-600' : 'text-red-600'}>
|
||||
{game.popularity_score > 0 ? '+' : ''}{game.popularity_score}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 flex items-center justify-center h-64">
|
||||
<p className="text-gray-500 text-lg">Select a session to view details</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Close Session Modal */}
|
||||
{closingSession && (
|
||||
<CloseSessionModal
|
||||
sessionId={closingSession}
|
||||
onClose={() => setClosingSession(null)}
|
||||
onConfirm={handleCloseSession}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CloseSessionModal({ sessionId, onClose, onConfirm }) {
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-8 max-w-md w-full">
|
||||
<h2 className="text-2xl font-bold mb-4">Close Session #{sessionId}</h2>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 font-semibold mb-2">
|
||||
Session Notes (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg h-32"
|
||||
placeholder="Add any notes about this session..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => onConfirm(sessionId, notes)}
|
||||
className="flex-1 bg-indigo-600 text-white py-3 rounded-lg hover:bg-indigo-700 transition"
|
||||
>
|
||||
Close Session
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 bg-gray-600 text-white py-3 rounded-lg hover:bg-gray-700 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatImportPanel({ sessionId, onClose, onImportComplete }) {
|
||||
const [chatData, setChatData] = useState('');
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!chatData.trim()) {
|
||||
alert('Please enter chat data');
|
||||
return;
|
||||
}
|
||||
|
||||
setImporting(true);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const parsedData = JSON.parse(chatData);
|
||||
const response = await api.post(`/sessions/${sessionId}/chat-import`, {
|
||||
chatData: parsedData
|
||||
});
|
||||
setResult(response.data);
|
||||
setTimeout(() => {
|
||||
onImportComplete();
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
alert('Import failed: ' + (err.response?.data?.error || err.message));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 border border-gray-300 rounded-lg p-6 mb-6">
|
||||
<h3 className="text-xl font-semibold mb-4">Import Chat Log</h3>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Paste JSON array with format: [{"{"}"username": "...", "message": "...", "timestamp": "..."{"}"}]
|
||||
<br />
|
||||
The system will detect "thisgame++" and "thisgame--" patterns and update game popularity.
|
||||
</p>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 font-semibold mb-2">Chat JSON Data</label>
|
||||
<textarea
|
||||
value={chatData}
|
||||
onChange={(e) => setChatData(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg h-48 font-mono text-sm"
|
||||
placeholder='[{"username":"Alice","message":"thisgame++","timestamp":"2024-01-01T12:00:00Z"}]'
|
||||
disabled={importing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="mb-4 p-4 bg-green-50 border border-green-300 rounded-lg">
|
||||
<p className="font-semibold text-green-800">Import Successful!</p>
|
||||
<p className="text-sm text-green-700">
|
||||
Imported {result.messagesImported} messages, processed {result.votesProcessed} votes
|
||||
</p>
|
||||
{result.votesByGame && Object.keys(result.votesByGame).length > 0 && (
|
||||
<div className="mt-2 text-sm">
|
||||
<p className="font-semibold">Votes by game:</p>
|
||||
<ul className="list-disc list-inside">
|
||||
{Object.values(result.votesByGame).map((vote, i) => (
|
||||
<li key={i}>
|
||||
{vote.title}: +{vote.upvotes} / -{vote.downvotes}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={importing}
|
||||
className="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition disabled:bg-gray-400"
|
||||
>
|
||||
{importing ? 'Importing...' : 'Import'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="bg-gray-600 text-white px-6 py-2 rounded-lg hover:bg-gray-700 transition"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default History;
|
||||
|
||||
155
frontend/src/pages/Home.jsx
Normal file
155
frontend/src/pages/Home.jsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import api from '../api/axios';
|
||||
|
||||
function Home() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [activeSession, setActiveSession] = useState(null);
|
||||
const [sessionGames, setSessionGames] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadActiveSession();
|
||||
}, []);
|
||||
|
||||
const loadActiveSession = async () => {
|
||||
try {
|
||||
const response = await api.get('/sessions/active');
|
||||
setActiveSession(response.data);
|
||||
|
||||
if (response.data?.id) {
|
||||
const gamesResponse = await api.get(`/sessions/${response.data.id}/games`);
|
||||
setSessionGames(gamesResponse.data);
|
||||
}
|
||||
} catch (error) {
|
||||
// No active session is okay
|
||||
setActiveSession(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="text-xl text-gray-600">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-4xl font-bold mb-8 text-gray-800">
|
||||
Welcome to Jackbox Game Picker
|
||||
</h1>
|
||||
|
||||
{activeSession ? (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-8">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-green-600 mb-2">
|
||||
Live Session Active
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
Started: {new Date(activeSession.created_at).toLocaleString()}
|
||||
</p>
|
||||
{activeSession.notes && (
|
||||
<p className="text-gray-600 mt-2">Notes: {activeSession.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
{isAuthenticated && (
|
||||
<Link
|
||||
to="/picker"
|
||||
className="bg-indigo-600 text-white px-6 py-3 rounded-lg hover:bg-indigo-700 transition"
|
||||
>
|
||||
Pick a Game
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sessionGames.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-xl font-semibold mb-4">Games Played This Session</h3>
|
||||
<div className="space-y-2">
|
||||
{sessionGames.map((game, index) => (
|
||||
<div
|
||||
key={game.id}
|
||||
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700">
|
||||
{index + 1}. {game.title}
|
||||
</span>
|
||||
<span className="text-gray-500 ml-2">({game.pack_name})</span>
|
||||
{game.manually_added === 1 && (
|
||||
<span className="ml-2 text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
|
||||
Manual
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">
|
||||
{new Date(game.played_at).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-700 mb-4">
|
||||
No Active Session
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
There is currently no game session in progress.
|
||||
</p>
|
||||
{isAuthenticated ? (
|
||||
<Link
|
||||
to="/picker"
|
||||
className="inline-block bg-indigo-600 text-white px-6 py-3 rounded-lg hover:bg-indigo-700 transition"
|
||||
>
|
||||
Start a New Session
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-gray-500">
|
||||
Admin access required to start a new session.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<Link
|
||||
to="/history"
|
||||
className="bg-white rounded-lg shadow-lg p-6 hover:shadow-xl transition"
|
||||
>
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">
|
||||
Session History
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
View past gaming sessions and the games that were played
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
{isAuthenticated && (
|
||||
<Link
|
||||
to="/manager"
|
||||
className="bg-white rounded-lg shadow-lg p-6 hover:shadow-xl transition"
|
||||
>
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">
|
||||
Game Manager
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Manage games, packs, and view statistics
|
||||
</p>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
|
||||
79
frontend/src/pages/Login.jsx
Normal file
79
frontend/src/pages/Login.jsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
function Login() {
|
||||
const [key, setKey] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login, isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate('/');
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
const result = await login(key);
|
||||
|
||||
if (result.success) {
|
||||
navigate('/');
|
||||
} else {
|
||||
setError(result.error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8">
|
||||
<h1 className="text-3xl font-bold mb-6 text-gray-800">Admin Login</h1>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="key" className="block text-gray-700 font-semibold mb-2">
|
||||
Admin Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="key"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="Enter admin key"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-indigo-600 text-white py-3 rounded-lg hover:bg-indigo-700 transition disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-sm text-gray-600 text-center">
|
||||
Admin privileges are required to manage games and sessions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
|
||||
502
frontend/src/pages/Manager.jsx
Normal file
502
frontend/src/pages/Manager.jsx
Normal file
@@ -0,0 +1,502 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import api from '../api/axios';
|
||||
|
||||
function Manager() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [games, setGames] = useState([]);
|
||||
const [packs, setPacks] = useState([]);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedPack, setSelectedPack] = useState('all');
|
||||
const [editingGame, setEditingGame] = useState(null);
|
||||
const [showAddGame, setShowAddGame] = useState(false);
|
||||
const [showImportExport, setShowImportExport] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
loadData();
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [gamesRes, packsRes, statsRes] = await Promise.all([
|
||||
api.get('/games'),
|
||||
api.get('/games/meta/packs'),
|
||||
api.get('/stats')
|
||||
]);
|
||||
|
||||
setGames(gamesRes.data);
|
||||
setPacks(packsRes.data);
|
||||
setStats(statsRes.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load data', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleGame = async (gameId) => {
|
||||
try {
|
||||
await api.patch(`/games/${gameId}/toggle`);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
alert('Failed to toggle game');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePack = async (packName, enabled) => {
|
||||
try {
|
||||
await api.patch(`/games/packs/${encodeURIComponent(packName)}/toggle`, { enabled });
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
alert('Failed to toggle pack');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteGame = async (gameId) => {
|
||||
if (!confirm('Are you sure you want to delete this game?')) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/games/${gameId}`);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
alert('Failed to delete game');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportCSV = async () => {
|
||||
try {
|
||||
const response = await api.get('/games/export/csv', {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', 'games-export.csv');
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
} catch (err) {
|
||||
alert('Failed to export CSV');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredGames = selectedPack === 'all'
|
||||
? games
|
||||
: games.filter(g => g.pack_name === selectedPack);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="text-xl text-gray-600">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-4xl font-bold mb-8 text-gray-800">Game Manager</h1>
|
||||
|
||||
{/* Statistics */}
|
||||
{stats && (
|
||||
<div className="grid md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="text-3xl font-bold text-indigo-600">{stats.games.count}</div>
|
||||
<div className="text-gray-600">Total Games</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="text-3xl font-bold text-green-600">{stats.gamesEnabled.count}</div>
|
||||
<div className="text-gray-600">Enabled Games</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="text-3xl font-bold text-blue-600">{stats.packs.count}</div>
|
||||
<div className="text-gray-600">Total Packs</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="text-3xl font-bold text-purple-600">{stats.totalGamesPlayed.count}</div>
|
||||
<div className="text-gray-600">Games Played</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-4 mb-6">
|
||||
<button
|
||||
onClick={() => setShowAddGame(true)}
|
||||
className="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition"
|
||||
>
|
||||
+ Add Game
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowImportExport(!showImportExport)}
|
||||
className="bg-gray-600 text-white px-6 py-2 rounded-lg hover:bg-gray-700 transition"
|
||||
>
|
||||
Import/Export
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
className="bg-green-600 text-white px-6 py-2 rounded-lg hover:bg-green-700 transition"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Import/Export Panel */}
|
||||
{showImportExport && (
|
||||
<ImportExportPanel onClose={() => setShowImportExport(false)} onImportComplete={loadData} />
|
||||
)}
|
||||
|
||||
{/* Add/Edit Game Form */}
|
||||
{(showAddGame || editingGame) && (
|
||||
<GameForm
|
||||
game={editingGame}
|
||||
onClose={() => {
|
||||
setShowAddGame(false);
|
||||
setEditingGame(null);
|
||||
}}
|
||||
onSave={loadData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pack Management */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-800">Pack Management</h2>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{packs.map(pack => (
|
||||
<div key={pack.pack_name} className="border border-gray-300 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-lg text-gray-800 mb-2">{pack.pack_name}</h3>
|
||||
<p className="text-gray-600 text-sm mb-3">
|
||||
{pack.enabled_count} / {pack.game_count} games enabled
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleTogglePack(pack.pack_name, true)}
|
||||
className="flex-1 bg-green-600 text-white px-3 py-1 rounded text-sm hover:bg-green-700 transition"
|
||||
>
|
||||
Enable All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTogglePack(pack.pack_name, false)}
|
||||
className="flex-1 bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700 transition"
|
||||
>
|
||||
Disable All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Game List */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-2xl font-semibold text-gray-800">Games</h2>
|
||||
<select
|
||||
value={selectedPack}
|
||||
onChange={(e) => setSelectedPack(e.target.value)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="all">All Packs</option>
|
||||
{packs.map(pack => (
|
||||
<option key={pack.pack_name} value={pack.pack_name}>
|
||||
{pack.pack_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Status</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Title</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Pack</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Players</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Type</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Plays</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Score</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredGames.map(game => (
|
||||
<tr key={game.id} className="border-b hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => handleToggleGame(game.id)}
|
||||
className={`px-3 py-1 rounded text-sm font-semibold ${
|
||||
game.enabled
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{game.enabled ? 'Enabled' : 'Disabled'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-semibold text-gray-800">{game.title}</td>
|
||||
<td className="px-4 py-3 text-gray-600 text-sm">{game.pack_name}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{game.min_players}-{game.max_players}</td>
|
||||
<td className="px-4 py-3 text-gray-600 text-sm">{game.game_type || 'N/A'}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{game.play_count}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={game.popularity_score >= 0 ? 'text-green-600' : 'text-red-600'}>
|
||||
{game.popularity_score > 0 ? '+' : ''}{game.popularity_score}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setEditingGame(game)}
|
||||
className="text-blue-600 hover:text-blue-800 text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteGame(game.id)}
|
||||
className="text-red-600 hover:text-red-800 text-sm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GameForm({ game, onClose, onSave }) {
|
||||
const [formData, setFormData] = useState(game || {
|
||||
pack_name: '',
|
||||
title: '',
|
||||
min_players: 1,
|
||||
max_players: 8,
|
||||
length_minutes: '',
|
||||
has_audience: false,
|
||||
family_friendly: false,
|
||||
game_type: '',
|
||||
secondary_type: ''
|
||||
});
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
if (game) {
|
||||
await api.put(`/games/${game.id}`, formData);
|
||||
} else {
|
||||
await api.post('/games', formData);
|
||||
}
|
||||
onSave();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
alert('Failed to save game');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-8 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="text-2xl font-bold mb-6">{game ? 'Edit Game' : 'Add New Game'}</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-gray-700 font-semibold mb-2">Title *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({...formData, title: e.target.value})}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gray-700 font-semibold mb-2">Pack Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.pack_name}
|
||||
onChange={(e) => setFormData({...formData, pack_name: e.target.value})}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gray-700 font-semibold mb-2">Min Players *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.min_players}
|
||||
onChange={(e) => setFormData({...formData, min_players: parseInt(e.target.value)})}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gray-700 font-semibold mb-2">Max Players *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.max_players}
|
||||
onChange={(e) => setFormData({...formData, max_players: parseInt(e.target.value)})}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gray-700 font-semibold mb-2">Length (minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.length_minutes}
|
||||
onChange={(e) => setFormData({...formData, length_minutes: e.target.value ? parseInt(e.target.value) : ''})}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gray-700 font-semibold mb-2">Game Type</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.game_type}
|
||||
onChange={(e) => setFormData({...formData, game_type: e.target.value})}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gray-700 font-semibold mb-2">Secondary Type</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.secondary_type}
|
||||
onChange={(e) => setFormData({...formData, secondary_type: e.target.value})}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.has_audience}
|
||||
onChange={(e) => setFormData({...formData, has_audience: e.target.checked})}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-gray-700">Has Audience</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.family_friendly}
|
||||
onChange={(e) => setFormData({...formData, family_friendly: e.target.checked})}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-gray-700">Family Friendly</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 bg-indigo-600 text-white py-3 rounded-lg hover:bg-indigo-700 transition"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 bg-gray-600 text-white py-3 rounded-lg hover:bg-gray-700 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportExportPanel({ onClose, onImportComplete }) {
|
||||
const [csvData, setCsvData] = useState('');
|
||||
const [importMode, setImportMode] = useState('append');
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!csvData.trim()) {
|
||||
alert('Please enter CSV data');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post('/games/import/csv', { csvData, mode: importMode });
|
||||
alert('Import successful!');
|
||||
onImportComplete();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
alert('Import failed: ' + (err.response?.data?.error || err.message));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||
<h3 className="text-xl font-semibold mb-4">Import Games from CSV</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 font-semibold mb-2">Import Mode</label>
|
||||
<select
|
||||
value={importMode}
|
||||
onChange={(e) => setImportMode(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="append">Append (keep existing games)</option>
|
||||
<option value="replace">Replace (delete all existing games)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 font-semibold mb-2">CSV Data</label>
|
||||
<textarea
|
||||
value={csvData}
|
||||
onChange={(e) => setCsvData(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg h-48 font-mono text-sm"
|
||||
placeholder="Paste CSV data here..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={handleImport}
|
||||
className="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="bg-gray-600 text-white px-6 py-2 rounded-lg hover:bg-gray-700 transition"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Manager;
|
||||
|
||||
388
frontend/src/pages/Picker.jsx
Normal file
388
frontend/src/pages/Picker.jsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import api from '../api/axios';
|
||||
|
||||
function Picker() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [activeSession, setActiveSession] = useState(null);
|
||||
const [allGames, setAllGames] = useState([]);
|
||||
const [selectedGame, setSelectedGame] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [picking, setPicking] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Filters
|
||||
const [playerCount, setPlayerCount] = useState('');
|
||||
const [drawingFilter, setDrawingFilter] = useState('both');
|
||||
const [lengthFilter, setLengthFilter] = useState('');
|
||||
const [familyFriendlyFilter, setFamilyFriendlyFilter] = useState('');
|
||||
|
||||
// Manual game selection
|
||||
const [showManualSelect, setShowManualSelect] = useState(false);
|
||||
const [manualGameId, setManualGameId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
loadData();
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// Load active session or create one
|
||||
try {
|
||||
const sessionResponse = await api.get('/sessions/active');
|
||||
setActiveSession(sessionResponse.data);
|
||||
} catch (err) {
|
||||
// No active session, create one
|
||||
const newSession = await api.post('/sessions', {});
|
||||
setActiveSession(newSession.data);
|
||||
}
|
||||
|
||||
// Load all games for manual selection
|
||||
const gamesResponse = await api.get('/games');
|
||||
setAllGames(gamesResponse.data);
|
||||
} catch (err) {
|
||||
setError('Failed to load session data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePickGame = async () => {
|
||||
if (!activeSession) return;
|
||||
|
||||
setPicking(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await api.post('/pick', {
|
||||
sessionId: activeSession.id,
|
||||
playerCount: playerCount ? parseInt(playerCount) : undefined,
|
||||
drawing: drawingFilter !== 'both' ? drawingFilter : undefined,
|
||||
length: lengthFilter || undefined,
|
||||
familyFriendly: familyFriendlyFilter ? familyFriendlyFilter === 'yes' : undefined
|
||||
});
|
||||
|
||||
setSelectedGame(response.data.game);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to pick a game');
|
||||
setSelectedGame(null);
|
||||
} finally {
|
||||
setPicking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAcceptGame = async () => {
|
||||
if (!selectedGame || !activeSession) return;
|
||||
|
||||
try {
|
||||
await api.post(`/sessions/${activeSession.id}/games`, {
|
||||
game_id: selectedGame.id,
|
||||
manually_added: false
|
||||
});
|
||||
|
||||
// Reload data
|
||||
await loadData();
|
||||
setSelectedGame(null);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError('Failed to add game to session');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddManualGame = async () => {
|
||||
if (!manualGameId || !activeSession) return;
|
||||
|
||||
try {
|
||||
await api.post(`/sessions/${activeSession.id}/games`, {
|
||||
game_id: parseInt(manualGameId),
|
||||
manually_added: true
|
||||
});
|
||||
|
||||
// Reload data
|
||||
await loadData();
|
||||
setManualGameId('');
|
||||
setShowManualSelect(false);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError('Failed to add game to session');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="text-xl text-gray-600">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeSession) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 p-4 rounded">
|
||||
Failed to load or create session. Please try again.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-4xl font-bold mb-8 text-gray-800">Game Picker</h1>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{/* Filters Panel */}
|
||||
<div className="md:col-span-1">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-800">Filters</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-gray-700 font-semibold mb-2">
|
||||
Player Count
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={playerCount}
|
||||
onChange={(e) => setPlayerCount(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="Any"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gray-700 font-semibold mb-2">
|
||||
Drawing Games
|
||||
</label>
|
||||
<select
|
||||
value={drawingFilter}
|
||||
onChange={(e) => setDrawingFilter(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="both">Both</option>
|
||||
<option value="only">Only Drawing</option>
|
||||
<option value="exclude">No Drawing</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gray-700 font-semibold mb-2">
|
||||
Game Length
|
||||
</label>
|
||||
<select
|
||||
value={lengthFilter}
|
||||
onChange={(e) => setLengthFilter(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="">Any</option>
|
||||
<option value="short">Short (≤15 min)</option>
|
||||
<option value="medium">Medium (16-25 min)</option>
|
||||
<option value="long">Long (>25 min)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gray-700 font-semibold mb-2">
|
||||
Family Friendly
|
||||
</label>
|
||||
<select
|
||||
value={familyFriendlyFilter}
|
||||
onChange={(e) => setFamilyFriendlyFilter(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="">Any</option>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handlePickGame}
|
||||
disabled={picking}
|
||||
className="w-full bg-indigo-600 text-white py-3 rounded-lg hover:bg-indigo-700 transition disabled:bg-gray-400 disabled:cursor-not-allowed font-semibold text-lg"
|
||||
>
|
||||
{picking ? 'Rolling...' : '🎲 Roll the Dice'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowManualSelect(!showManualSelect)}
|
||||
className="w-full bg-gray-600 text-white py-2 rounded-lg hover:bg-gray-700 transition"
|
||||
>
|
||||
{showManualSelect ? 'Cancel' : 'Manual Selection'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Panel */}
|
||||
<div className="md:col-span-2">
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 p-4 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedGame && (
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 mb-6">
|
||||
<h2 className="text-3xl font-bold mb-4 text-gray-800">
|
||||
{selectedGame.title}
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 mb-4">{selectedGame.pack_name}</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700">Players:</span>
|
||||
<span className="ml-2 text-gray-600">
|
||||
{selectedGame.min_players}-{selectedGame.max_players}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700">Length:</span>
|
||||
<span className="ml-2 text-gray-600">
|
||||
{selectedGame.length_minutes ? `${selectedGame.length_minutes} min` : 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700">Type:</span>
|
||||
<span className="ml-2 text-gray-600">
|
||||
{selectedGame.game_type || 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700">Family Friendly:</span>
|
||||
<span className="ml-2 text-gray-600">
|
||||
{selectedGame.family_friendly ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700">Play Count:</span>
|
||||
<span className="ml-2 text-gray-600">{selectedGame.play_count}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700">Popularity:</span>
|
||||
<span className="ml-2 text-gray-600">
|
||||
{selectedGame.popularity_score > 0 ? '+' : ''}
|
||||
{selectedGame.popularity_score}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={handleAcceptGame}
|
||||
className="flex-1 bg-green-600 text-white py-3 rounded-lg hover:bg-green-700 transition font-semibold"
|
||||
>
|
||||
✓ Play This Game
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePickGame}
|
||||
className="flex-1 bg-yellow-600 text-white py-3 rounded-lg hover:bg-yellow-700 transition font-semibold"
|
||||
>
|
||||
🎲 Re-roll
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showManualSelect && (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||
<h3 className="text-xl font-semibold mb-4 text-gray-800">
|
||||
Manual Game Selection
|
||||
</h3>
|
||||
<div className="flex gap-4">
|
||||
<select
|
||||
value={manualGameId}
|
||||
onChange={(e) => setManualGameId(e.target.value)}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="">Select a game...</option>
|
||||
{allGames.map((game) => (
|
||||
<option key={game.id} value={game.id}>
|
||||
{game.title} ({game.pack_name})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={handleAddManualGame}
|
||||
disabled={!manualGameId}
|
||||
className="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition disabled:bg-gray-400"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session info and games */}
|
||||
<SessionInfo sessionId={activeSession.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionInfo({ sessionId }) {
|
||||
const [games, setGames] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadGames();
|
||||
}, [sessionId]);
|
||||
|
||||
const loadGames = async () => {
|
||||
try {
|
||||
const response = await api.get(`/sessions/${sessionId}/games`);
|
||||
setGames(response.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load session games');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-xl font-semibold mb-4 text-gray-800">
|
||||
Games Played This Session ({games.length})
|
||||
</h3>
|
||||
{loading ? (
|
||||
<p className="text-gray-500">Loading...</p>
|
||||
) : games.length === 0 ? (
|
||||
<p className="text-gray-500">No games played yet. Pick a game to get started!</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{games.map((game, index) => (
|
||||
<div key={game.id} className="flex items-center justify-between p-3 bg-gray-50 rounded">
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700">
|
||||
{index + 1}. {game.title}
|
||||
</span>
|
||||
<span className="text-gray-500 ml-2 text-sm">({game.pack_name})</span>
|
||||
{game.manually_added === 1 && (
|
||||
<span className="ml-2 text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
|
||||
Manual
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">
|
||||
{new Date(game.played_at).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Picker;
|
||||
|
||||
12
frontend/tailwind.config.js
Normal file
12
frontend/tailwind.config.js
Normal 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
17
frontend/vite.config.js
Normal 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
60
games-list.csv
Normal 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,?,?
|
||||
|
Reference in New Issue
Block a user