From 2db707961cf5c92184657f0b99a81c5cabe7b79f Mon Sep 17 00:00:00 2001 From: cottongin Date: Thu, 30 Oct 2025 04:27:43 -0400 Subject: [PATCH] initial commit --- .gitignore | 39 +++ README.md | 371 ++++++++++++++++++++ backend/.gitkeep | 0 backend/Dockerfile | 22 ++ backend/bootstrap.js | 73 ++++ backend/database.js | 83 +++++ backend/middleware/auth.js | 23 ++ backend/package.json | 26 ++ backend/routes/auth.js | 44 +++ backend/routes/games.js | 364 +++++++++++++++++++ backend/routes/picker.js | 100 ++++++ backend/routes/sessions.js | 340 ++++++++++++++++++ backend/routes/stats.js | 39 +++ backend/server.js | 43 +++ docker-compose.yml | 43 +++ frontend/.gitkeep | 0 frontend/Dockerfile | 30 ++ frontend/index.html | 14 + frontend/nginx.conf | 32 ++ frontend/package.json | 27 ++ frontend/postcss.config.js | 7 + frontend/src/App.jsx | 76 ++++ frontend/src/api/axios.js | 17 + frontend/src/context/AuthContext.jsx | 70 ++++ frontend/src/index.css | 18 + frontend/src/main.jsx | 17 + frontend/src/pages/History.jsx | 356 +++++++++++++++++++ frontend/src/pages/Home.jsx | 155 +++++++++ frontend/src/pages/Login.jsx | 79 +++++ frontend/src/pages/Manager.jsx | 502 +++++++++++++++++++++++++++ frontend/src/pages/Picker.jsx | 388 +++++++++++++++++++++ frontend/tailwind.config.js | 12 + frontend/vite.config.js | 17 + games-list.csv | 60 ++++ 34 files changed, 3487 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/.gitkeep create mode 100644 backend/Dockerfile create mode 100644 backend/bootstrap.js create mode 100644 backend/database.js create mode 100644 backend/middleware/auth.js create mode 100644 backend/package.json create mode 100644 backend/routes/auth.js create mode 100644 backend/routes/games.js create mode 100644 backend/routes/picker.js create mode 100644 backend/routes/sessions.js create mode 100644 backend/routes/stats.js create mode 100644 backend/server.js create mode 100644 docker-compose.yml create mode 100644 frontend/.gitkeep create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/api/axios.js create mode 100644 frontend/src/context/AuthContext.jsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/History.jsx create mode 100644 frontend/src/pages/Home.jsx create mode 100644 frontend/src/pages/Login.jsx create mode 100644 frontend/src/pages/Manager.jsx create mode 100644 frontend/src/pages/Picker.jsx create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/vite.config.js create mode 100644 games-list.csv diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b8a2e9 --- /dev/null +++ b/.gitignore @@ -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 + diff --git a/README.md b/README.md new file mode 100644 index 0000000..6d3f8fd --- /dev/null +++ b/README.md @@ -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 + 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! + diff --git a/backend/.gitkeep b/backend/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..50979c2 --- /dev/null +++ b/backend/Dockerfile @@ -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"] + diff --git a/backend/bootstrap.js b/backend/bootstrap.js new file mode 100644 index 0000000..09e516d --- /dev/null +++ b/backend/bootstrap.js @@ -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 }; + diff --git a/backend/database.js b/backend/database.js new file mode 100644 index 0000000..ee070ca --- /dev/null +++ b/backend/database.js @@ -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; + diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js new file mode 100644 index 0000000..b4c750f --- /dev/null +++ b/backend/middleware/auth.js @@ -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 }; + diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..23300c9 --- /dev/null +++ b/backend/package.json @@ -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" + } +} + diff --git a/backend/routes/auth.js b/backend/routes/auth.js new file mode 100644 index 0000000..818c195 --- /dev/null +++ b/backend/routes/auth.js @@ -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; + diff --git a/backend/routes/games.js b/backend/routes/games.js new file mode 100644 index 0000000..b4b73fc --- /dev/null +++ b/backend/routes/games.js @@ -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; + diff --git a/backend/routes/picker.js b/backend/routes/picker.js new file mode 100644 index 0000000..5c87a7f --- /dev/null +++ b/backend/routes/picker.js @@ -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; + diff --git a/backend/routes/sessions.js b/backend/routes/sessions.js new file mode 100644 index 0000000..8dbf5f9 --- /dev/null +++ b/backend/routes/sessions.js @@ -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; + diff --git a/backend/routes/stats.js b/backend/routes/stats.js new file mode 100644 index 0000000..a83fe03 --- /dev/null +++ b/backend/routes/stats.js @@ -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; + diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..90f5ba8 --- /dev/null +++ b/backend/server.js @@ -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}`); +}); + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..befee79 --- /dev/null +++ b/docker-compose.yml @@ -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: + diff --git a/frontend/.gitkeep b/frontend/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..97b4370 --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d3b7f4e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + Jackbox Game Picker + + +
+ + + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..aab36ee --- /dev/null +++ b/frontend/nginx.conf @@ -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"; + } +} + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..661b474 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} + diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..b4a6220 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,7 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..ca11de2 --- /dev/null +++ b/frontend/src/App.jsx @@ -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 ( +
+ {/* Navigation */} + + + {/* Main Content */} +
+ + } /> + } /> + } /> + } /> + } /> + +
+
+ ); +} + +export default App; + diff --git a/frontend/src/api/axios.js b/frontend/src/api/axios.js new file mode 100644 index 0000000..e407d55 --- /dev/null +++ b/frontend/src/api/axios.js @@ -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; + diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx new file mode 100644 index 0000000..e2e4717 --- /dev/null +++ b/frontend/src/context/AuthContext.jsx @@ -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 {children}; +}; + diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..21ee7bf --- /dev/null +++ b/frontend/src/index.css @@ -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; +} + diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..4788435 --- /dev/null +++ b/frontend/src/main.jsx @@ -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( + + + + + + + , +); + diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx new file mode 100644 index 0000000..280cb49 --- /dev/null +++ b/frontend/src/pages/History.jsx @@ -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 ( +
+
Loading...
+
+ ); + } + + return ( +
+

Session History

+ +
+ {/* Sessions List */} +
+
+

Sessions

+ + {sessions.length === 0 ? ( +

No sessions found

+ ) : ( +
+ {sessions.map(session => ( +
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' + }`} + > +
+
+ Session #{session.id} +
+ {session.is_active === 1 && ( + + Active + + )} +
+
+ {new Date(session.created_at).toLocaleDateString()} +
+
+ {session.games_played} game{session.games_played !== 1 ? 's' : ''} played +
+ + {isAuthenticated && session.is_active === 1 && ( + + )} +
+ ))} +
+ )} +
+
+ + {/* Session Details */} +
+ {selectedSession ? ( +
+
+
+

+ Session #{selectedSession} +

+

+ {sessions.find(s => s.id === selectedSession)?.created_at && + new Date(sessions.find(s => s.id === selectedSession).created_at).toLocaleString()} +

+
+ + {isAuthenticated && sessions.find(s => s.id === selectedSession)?.is_active === 1 && ( + + )} +
+ + {showChatImport && ( + setShowChatImport(false)} + onImportComplete={() => { + loadSessionGames(selectedSession); + setShowChatImport(false); + }} + /> + )} + + {sessionGames.length === 0 ? ( +

No games played in this session

+ ) : ( +
+

+ Games Played ({sessionGames.length}) +

+
+ {sessionGames.map((game, index) => ( +
+
+
+
+ {index + 1}. {game.title} +
+
{game.pack_name}
+
+
+
+ {new Date(game.played_at).toLocaleTimeString()} +
+ {game.manually_added === 1 && ( + + Manual + + )} +
+
+ +
+
+ Players: {game.min_players}-{game.max_players} +
+
+ Type: {game.game_type || 'N/A'} +
+
+ Popularity:{' '} + = 0 ? 'text-green-600' : 'text-red-600'}> + {game.popularity_score > 0 ? '+' : ''}{game.popularity_score} + +
+
+
+ ))} +
+
+ )} +
+ ) : ( +
+

Select a session to view details

+
+ )} +
+
+ + {/* Close Session Modal */} + {closingSession && ( + setClosingSession(null)} + onConfirm={handleCloseSession} + /> + )} +
+ ); +} + +function CloseSessionModal({ sessionId, onClose, onConfirm }) { + const [notes, setNotes] = useState(''); + + return ( +
+
+

Close Session #{sessionId}

+ +
+ +