diff --git a/.gitignore b/.gitignore index 7b8a2e9..a5b4f4c 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,10 @@ Thumbs.db # Docker .dockerignore +# Local development +.local/ +.old-chrome-extension/ + # Cursor .cursor/ chat-summaries/ diff --git a/backend/Dockerfile b/backend/Dockerfile index 50979c2..9c4e435 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,11 +2,14 @@ FROM node:18-alpine WORKDIR /app +# Install wget for healthcheck +RUN apk add --no-cache wget + # Copy package files COPY package*.json ./ # Install dependencies -RUN npm ci --only=production +RUN npm install --omit=dev # Copy application files COPY . . diff --git a/backend/database.js b/backend/database.js index ee070ca..db711f2 100644 --- a/backend/database.js +++ b/backend/database.js @@ -5,9 +5,17 @@ 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 }); +// Ensure data directory exists with proper permissions +try { + if (!fs.existsSync(dbDir)) { + fs.mkdirSync(dbDir, { recursive: true, mode: 0o777 }); + } + // Also ensure the directory is writable + fs.accessSync(dbDir, fs.constants.W_OK); +} catch (err) { + console.error(`Error with database directory ${dbDir}:`, err.message); + console.error('Please ensure the directory exists and is writable'); + process.exit(1); } const db = new Database(dbPath); @@ -56,10 +64,41 @@ function initializeDatabase() { game_id INTEGER NOT NULL, played_at DATETIME DEFAULT CURRENT_TIMESTAMP, manually_added INTEGER DEFAULT 0, + status TEXT DEFAULT 'played', FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE, FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE ) `); + + // Add status column if it doesn't exist (for existing databases) + try { + db.exec(`ALTER TABLE session_games ADD COLUMN status TEXT DEFAULT 'played'`); + } catch (err) { + // Column already exists, ignore error + } + + // Add favor_bias column to games if it doesn't exist + try { + db.exec(`ALTER TABLE games ADD COLUMN favor_bias INTEGER DEFAULT 0`); + } catch (err) { + // Column already exists, ignore error + } + + // Packs table for pack-level favoriting + db.exec(` + CREATE TABLE IF NOT EXISTS packs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + favor_bias INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + // Populate packs table with unique pack names from games + db.exec(` + INSERT OR IGNORE INTO packs (name) + SELECT DISTINCT pack_name FROM games + `); // Chat logs table db.exec(` diff --git a/backend/routes/games.js b/backend/routes/games.js index b4b73fc..0922895 100644 --- a/backend/routes/games.js +++ b/backend/routes/games.js @@ -73,6 +73,91 @@ router.get('/', (req, res) => { } }); +// Get all packs with their favor bias (MUST be before /:id) +router.get('/packs', (req, res) => { + try { + const packs = db.prepare('SELECT * FROM packs ORDER BY name').all(); + res.json(packs); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Get pack metadata (MUST be before /:id) +router.get('/meta/packs', (req, res) => { + try { + const packs = db.prepare(` + SELECT + pack_name as name, + COUNT(*) as total_count, + SUM(CASE WHEN enabled = 1 THEN 1 ELSE 0 END) as enabled_count, + SUM(play_count) as total_plays + FROM games + GROUP BY pack_name + ORDER BY pack_name + `).all(); + res.json(packs); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Export games as CSV (MUST be before /:id) +router.get('/export/csv', authenticateToken, (req, res) => { + try { + const games = db.prepare('SELECT * FROM games ORDER BY pack_name, title').all(); + + const records = games.map(game => ({ + 'Pack Name': game.pack_name, + 'Title': game.title, + 'Min Players': game.min_players, + 'Max Players': game.max_players, + 'Length (minutes)': game.length_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(records, { header: true }); + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', 'attachment; filename="jackbox-games.csv"'); + res.send(csv); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Set favor bias for a pack (MUST be before /:id) +router.patch('/packs/:name/favor', authenticateToken, (req, res) => { + try { + const { favor_bias } = req.body; + + // Validate favor_bias value + if (![1, -1, 0].includes(favor_bias)) { + return res.status(400).json({ error: 'favor_bias must be 1 (favor), -1 (disfavor), or 0 (neutral)' }); + } + + // Update pack favor bias + const packStmt = db.prepare('UPDATE packs SET favor_bias = ? WHERE name = ?'); + const packResult = packStmt.run(favor_bias, req.params.name); + + if (packResult.changes === 0) { + // Pack doesn't exist, create it + const insertStmt = db.prepare('INSERT INTO packs (name, favor_bias) VALUES (?, ?)'); + insertStmt.run(req.params.name, favor_bias); + } + + res.json({ + message: 'Pack favor bias updated successfully', + favor_bias + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // Get single game by ID router.get('/:id', (req, res) => { try { @@ -224,25 +309,6 @@ router.patch('/:id/toggle', authenticateToken, (req, res) => { } }); -// 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 { @@ -264,33 +330,6 @@ router.patch('/packs/:name/toggle', authenticateToken, (req, res) => { } }); -// 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 { @@ -360,5 +399,31 @@ function parseBoolean(value) { return value.toLowerCase() === 'yes' ? 1 : 0; } +// Set favor bias for a game (1 = favor, -1 = disfavor, 0 = neutral) +router.patch('/:id/favor', authenticateToken, (req, res) => { + try { + const { favor_bias } = req.body; + + // Validate favor_bias value + if (![1, -1, 0].includes(favor_bias)) { + return res.status(400).json({ error: 'favor_bias must be 1 (favor), -1 (disfavor), or 0 (neutral)' }); + } + + const stmt = db.prepare('UPDATE games SET favor_bias = ? WHERE id = ?'); + const result = stmt.run(favor_bias, req.params.id); + + if (result.changes === 0) { + return res.status(404).json({ error: 'Game not found' }); + } + + res.json({ + message: 'Favor bias updated successfully', + favor_bias + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + module.exports = router; diff --git a/backend/routes/picker.js b/backend/routes/picker.js index 5c87a7f..c13c6b2 100644 --- a/backend/routes/picker.js +++ b/backend/routes/picker.js @@ -3,6 +3,68 @@ const db = require('../database'); const router = express.Router(); +/** + * Select a game using weighted random selection based on favor bias + * + * Bias system: + * - favor_bias = 1 (favored): 3x weight + * - favor_bias = 0 (neutral): 1x weight + * - favor_bias = -1 (disfavored): 0.2x weight (still possible but less likely) + * + * Also considers pack-level bias + */ +function selectGameWithBias(games) { + if (games.length === 0) return null; + if (games.length === 1) return games[0]; + + // Get pack biases + const packs = db.prepare('SELECT name, favor_bias FROM packs').all(); + const packBiasMap = {}; + packs.forEach(pack => { + packBiasMap[pack.name] = pack.favor_bias || 0; + }); + + // Calculate weights for each game + const weights = games.map(game => { + let weight = 1.0; // Base weight + + // Apply game-level bias + const gameBias = game.favor_bias || 0; + if (gameBias === 1) { + weight *= 3.0; // Favored games are 3x more likely + } else if (gameBias === -1) { + weight *= 0.2; // Disfavored games are 5x less likely + } + + // Apply pack-level bias + const packBias = packBiasMap[game.pack_name] || 0; + if (packBias === 1) { + weight *= 2.0; // Favored packs are 2x more likely + } else if (packBias === -1) { + weight *= 0.3; // Disfavored packs are ~3x less likely + } + + return weight; + }); + + // Calculate total weight + const totalWeight = weights.reduce((sum, w) => sum + w, 0); + + // Pick a random number between 0 and totalWeight + let random = Math.random() * totalWeight; + + // Select game based on weighted random + for (let i = 0; i < games.length; i++) { + random -= weights[i]; + if (random <= 0) { + return games[i]; + } + } + + // Fallback (shouldn't reach here) + return games[games.length - 1]; +} + // Pick a random game with filters and repeat avoidance router.post('/pick', (req, res) => { try { @@ -11,7 +73,8 @@ router.post('/pick', (req, res) => { drawing, length, familyFriendly, - sessionId + sessionId, + excludePlayed } = req.body; // Build query for eligible games @@ -60,14 +123,25 @@ router.post('/pick', (req, res) => { // 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); + let lastGamesQuery; + + if (excludePlayed) { + // Exclude ALL previously played games in this session + lastGamesQuery = db.prepare(` + SELECT DISTINCT game_id FROM session_games + WHERE session_id = ? + `).all(sessionId); + } else { + // Default: only exclude last 2 games + lastGamesQuery = 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); + const excludeIds = lastGamesQuery.map(g => g.game_id); if (excludeIds.length > 0) { eligibleGames = eligibleGames.filter(game => !excludeIds.includes(game.id)); @@ -75,16 +149,17 @@ router.post('/pick', (req, res) => { if (eligibleGames.length === 0) { return res.status(404).json({ - error: 'All eligible games have been played recently', + error: excludePlayed + ? 'All eligible games have been played in this session' + : '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]; + // Apply favor biasing to selection + const selectedGame = selectGameWithBias(eligibleGames); res.json({ game: selectedGame, diff --git a/backend/routes/sessions.js b/backend/routes/sessions.js index 8dbf5f9..d803270 100644 --- a/backend/routes/sessions.js +++ b/backend/routes/sessions.js @@ -37,8 +37,9 @@ router.get('/active', (req, res) => { LIMIT 1 `).get(); + // Return null instead of 404 when no active session if (!session) { - return res.status(404).json({ error: 'No active session found' }); + return res.json({ session: null, message: 'No active session' }); } res.json(session); @@ -114,6 +115,13 @@ router.post('/:id/close', authenticateToken, (req, res) => { return res.status(400).json({ error: 'Session is already closed' }); } + // Set all 'playing' games to 'played' before closing + db.prepare(` + UPDATE session_games + SET status = 'played' + WHERE session_id = ? AND status = 'playing' + `).run(req.params.id); + const stmt = db.prepare(` UPDATE sessions SET is_active = 0, closed_at = CURRENT_TIMESTAMP, notes = COALESCE(?, notes) @@ -129,6 +137,33 @@ router.post('/:id/close', authenticateToken, (req, res) => { } }); +// Delete session (admin only) +router.delete('/:id', authenticateToken, (req, res) => { + try { + const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(req.params.id); + + if (!session) { + return res.status(404).json({ error: 'Session not found' }); + } + + // Prevent deletion of active sessions + if (session.is_active === 1) { + return res.status(400).json({ error: 'Cannot delete an active session. Please close it first.' }); + } + + // Delete related data first (cascade) + db.prepare('DELETE FROM chat_logs WHERE session_id = ?').run(req.params.id); + db.prepare('DELETE FROM session_games WHERE session_id = ?').run(req.params.id); + + // Delete the session + db.prepare('DELETE FROM sessions WHERE id = ?').run(req.params.id); + + res.json({ message: 'Session deleted successfully', sessionId: parseInt(req.params.id) }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // Get games played in a session router.get('/:id/games', (req, res) => { try { @@ -180,10 +215,20 @@ router.post('/:id/games', authenticateToken, (req, res) => { return res.status(404).json({ error: 'Game not found' }); } - // Add game to session + // Set all current 'playing' games to 'played' (except skipped ones) + db.prepare(` + UPDATE session_games + SET status = CASE + WHEN status = 'skipped' THEN 'skipped' + ELSE 'played' + END + WHERE session_id = ? AND status = 'playing' + `).run(req.params.id); + + // Add game to session with 'playing' status const stmt = db.prepare(` - INSERT INTO session_games (session_id, game_id, manually_added) - VALUES (?, ?, ?) + INSERT INTO session_games (session_id, game_id, manually_added, status) + VALUES (?, ?, ?, 'playing') `); const result = stmt.run(req.params.id, game_id, manually_added ? 1 : 0); @@ -336,5 +381,64 @@ router.post('/:id/chat-import', authenticateToken, (req, res) => { } }); +// Update session game status (admin only) +router.patch('/:sessionId/games/:gameId/status', authenticateToken, (req, res) => { + try { + const { status } = req.body; + const { sessionId, gameId } = req.params; + + if (!status || !['playing', 'played', 'skipped'].includes(status)) { + return res.status(400).json({ error: 'Invalid status. Must be playing, played, or skipped' }); + } + + // If setting to 'playing', first set all other games in session to 'played' or keep as 'skipped' + if (status === 'playing') { + db.prepare(` + UPDATE session_games + SET status = CASE + WHEN status = 'skipped' THEN 'skipped' + ELSE 'played' + END + WHERE session_id = ? AND status = 'playing' + `).run(sessionId); + } + + // Update the specific game + const result = db.prepare(` + UPDATE session_games + SET status = ? + WHERE session_id = ? AND id = ? + `).run(status, sessionId, gameId); + + if (result.changes === 0) { + return res.status(404).json({ error: 'Session game not found' }); + } + + res.json({ message: 'Status updated successfully', status }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Delete session game (admin only) +router.delete('/:sessionId/games/:gameId', authenticateToken, (req, res) => { + try { + const { sessionId, gameId } = req.params; + + const result = db.prepare(` + DELETE FROM session_games + WHERE session_id = ? AND id = ? + `).run(sessionId, gameId); + + if (result.changes === 0) { + return res.status(404).json({ error: 'Session game not found' }); + } + + res.json({ message: 'Game removed from session successfully' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + module.exports = router; diff --git a/docker-compose.yml b/docker-compose.yml index befee79..7696cf0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: backend: build: @@ -14,12 +12,18 @@ services: - JWT_SECRET=${JWT_SECRET:-change-me-in-production} - ADMIN_KEY=${ADMIN_KEY:-admin123} volumes: - - ./backend/data:/app/data + - jackbox-data:/app/data - ./games-list.csv:/app/games-list.csv:ro ports: - "5000:5000" networks: - jackbox-network + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:5000/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s frontend: build: @@ -30,7 +34,8 @@ services: ports: - "3000:80" depends_on: - - backend + backend: + condition: service_healthy networks: - jackbox-network @@ -39,5 +44,6 @@ networks: driver: bridge volumes: - backend-data: + jackbox-data: + driver: local diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 97b4370..b1ee06f 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -7,7 +7,7 @@ WORKDIR /app COPY package*.json ./ # Install dependencies -RUN npm ci +RUN npm install # Copy application files COPY . . diff --git a/frontend/index.html b/frontend/index.html index d3b7f4e..b434497 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,11 +2,34 @@ - + + Jackbox Game Picker + + + + + + + + + + + + + + + + + - +
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index b4a6220..2ce518b 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -1,4 +1,4 @@ -export default { +module.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..00afeda --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ca11de2..04dbd1c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,10 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Routes, Route, Link } from 'react-router-dom'; import { useAuth } from './context/AuthContext'; +import { ToastProvider } from './components/Toast'; +import { branding } from './config/branding'; +import Logo from './components/Logo'; +import ThemeToggle from './components/ThemeToggle'; import Home from './pages/Home'; import Login from './pages/Login'; import Picker from './pages/Picker'; @@ -9,36 +13,46 @@ import History from './pages/History'; function App() { const { isAuthenticated, logout } = useAuth(); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + + const closeMobileMenu = () => setMobileMenuOpen(false); return ( -
+ +
{/* Navigation */} -