we're about to port the chrome-extension. everything else mostly works
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -32,6 +32,10 @@ Thumbs.db
|
|||||||
# Docker
|
# Docker
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
|
||||||
|
# Local development
|
||||||
|
.local/
|
||||||
|
.old-chrome-extension/
|
||||||
|
|
||||||
# Cursor
|
# Cursor
|
||||||
.cursor/
|
.cursor/
|
||||||
chat-summaries/
|
chat-summaries/
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ FROM node:18-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install wget for healthcheck
|
||||||
|
RUN apk add --no-cache wget
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN npm ci --only=production
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
# Copy application files
|
# Copy application files
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -5,9 +5,17 @@ const fs = require('fs');
|
|||||||
const dbPath = process.env.DB_PATH || path.join(__dirname, 'data', 'jackbox.db');
|
const dbPath = process.env.DB_PATH || path.join(__dirname, 'data', 'jackbox.db');
|
||||||
const dbDir = path.dirname(dbPath);
|
const dbDir = path.dirname(dbPath);
|
||||||
|
|
||||||
// Ensure data directory exists
|
// Ensure data directory exists with proper permissions
|
||||||
if (!fs.existsSync(dbDir)) {
|
try {
|
||||||
fs.mkdirSync(dbDir, { recursive: true });
|
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);
|
const db = new Database(dbPath);
|
||||||
@@ -56,10 +64,41 @@ function initializeDatabase() {
|
|||||||
game_id INTEGER NOT NULL,
|
game_id INTEGER NOT NULL,
|
||||||
played_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
played_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
manually_added INTEGER DEFAULT 0,
|
manually_added INTEGER DEFAULT 0,
|
||||||
|
status TEXT DEFAULT 'played',
|
||||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
|
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (game_id) REFERENCES games(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
|
// Chat logs table
|
||||||
db.exec(`
|
db.exec(`
|
||||||
|
|||||||
@@ -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
|
// Get single game by ID
|
||||||
router.get('/:id', (req, res) => {
|
router.get('/:id', (req, res) => {
|
||||||
try {
|
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)
|
// Toggle entire pack (admin only)
|
||||||
router.patch('/packs/:name/toggle', authenticateToken, (req, res) => {
|
router.patch('/packs/:name/toggle', authenticateToken, (req, res) => {
|
||||||
try {
|
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)
|
// Import games from CSV (admin only)
|
||||||
router.post('/import/csv', authenticateToken, (req, res) => {
|
router.post('/import/csv', authenticateToken, (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -360,5 +399,31 @@ function parseBoolean(value) {
|
|||||||
return value.toLowerCase() === 'yes' ? 1 : 0;
|
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;
|
module.exports = router;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,68 @@ const db = require('../database');
|
|||||||
|
|
||||||
const router = express.Router();
|
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
|
// Pick a random game with filters and repeat avoidance
|
||||||
router.post('/pick', (req, res) => {
|
router.post('/pick', (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -11,7 +73,8 @@ router.post('/pick', (req, res) => {
|
|||||||
drawing,
|
drawing,
|
||||||
length,
|
length,
|
||||||
familyFriendly,
|
familyFriendly,
|
||||||
sessionId
|
sessionId,
|
||||||
|
excludePlayed
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// Build query for eligible games
|
// Build query for eligible games
|
||||||
@@ -60,14 +123,25 @@ router.post('/pick', (req, res) => {
|
|||||||
|
|
||||||
// Apply repeat avoidance if session provided
|
// Apply repeat avoidance if session provided
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
const lastGames = db.prepare(`
|
let lastGamesQuery;
|
||||||
SELECT game_id FROM session_games
|
|
||||||
WHERE session_id = ?
|
if (excludePlayed) {
|
||||||
ORDER BY played_at DESC
|
// Exclude ALL previously played games in this session
|
||||||
LIMIT 2
|
lastGamesQuery = db.prepare(`
|
||||||
`).all(sessionId);
|
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) {
|
if (excludeIds.length > 0) {
|
||||||
eligibleGames = eligibleGames.filter(game => !excludeIds.includes(game.id));
|
eligibleGames = eligibleGames.filter(game => !excludeIds.includes(game.id));
|
||||||
@@ -75,16 +149,17 @@ router.post('/pick', (req, res) => {
|
|||||||
|
|
||||||
if (eligibleGames.length === 0) {
|
if (eligibleGames.length === 0) {
|
||||||
return res.status(404).json({
|
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',
|
suggestion: 'Enable more games or adjust your filters',
|
||||||
recentlyPlayed: excludeIds
|
recentlyPlayed: excludeIds
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick random game from eligible pool
|
// Apply favor biasing to selection
|
||||||
const randomIndex = Math.floor(Math.random() * eligibleGames.length);
|
const selectedGame = selectGameWithBias(eligibleGames);
|
||||||
const selectedGame = eligibleGames[randomIndex];
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
game: selectedGame,
|
game: selectedGame,
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ router.get('/active', (req, res) => {
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
`).get();
|
`).get();
|
||||||
|
|
||||||
|
// Return null instead of 404 when no active session
|
||||||
if (!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);
|
res.json(session);
|
||||||
@@ -114,6 +115,13 @@ router.post('/:id/close', authenticateToken, (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Session is already closed' });
|
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(`
|
const stmt = db.prepare(`
|
||||||
UPDATE sessions
|
UPDATE sessions
|
||||||
SET is_active = 0, closed_at = CURRENT_TIMESTAMP, notes = COALESCE(?, notes)
|
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
|
// Get games played in a session
|
||||||
router.get('/:id/games', (req, res) => {
|
router.get('/:id/games', (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -180,10 +215,20 @@ router.post('/:id/games', authenticateToken, (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Game not found' });
|
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(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO session_games (session_id, game_id, manually_added)
|
INSERT INTO session_games (session_id, game_id, manually_added, status)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?, 'playing')
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const result = stmt.run(req.params.id, game_id, manually_added ? 1 : 0);
|
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;
|
module.exports = router;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
@@ -14,12 +12,18 @@ services:
|
|||||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||||
- ADMIN_KEY=${ADMIN_KEY:-admin123}
|
- ADMIN_KEY=${ADMIN_KEY:-admin123}
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/data:/app/data
|
- jackbox-data:/app/data
|
||||||
- ./games-list.csv:/app/games-list.csv:ro
|
- ./games-list.csv:/app/games-list.csv:ro
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
networks:
|
networks:
|
||||||
- jackbox-network
|
- jackbox-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:5000/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
@@ -30,7 +34,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "3000:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- jackbox-network
|
- jackbox-network
|
||||||
|
|
||||||
@@ -39,5 +44,6 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
backend-data:
|
jackbox-data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ WORKDIR /app
|
|||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN npm ci
|
RUN npm install
|
||||||
|
|
||||||
# Copy application files
|
# Copy application files
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -2,11 +2,34 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
||||||
|
<!-- Branding -->
|
||||||
<title>Jackbox Game Picker</title>
|
<title>Jackbox Game Picker</title>
|
||||||
|
<meta name="description" content="A web app for managing and picking Jackbox Party Pack games" />
|
||||||
|
<meta name="keywords" content="jackbox, party pack, game picker, multiplayer games" />
|
||||||
|
<meta name="author" content="Jackbox Game Picker" />
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
|
||||||
|
<!-- Theme color -->
|
||||||
|
<meta name="theme-color" content="#4F46E5" />
|
||||||
|
|
||||||
|
<!-- Open Graph / Social Media -->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content="Jackbox Game Picker" />
|
||||||
|
<meta property="og:description" content="A web app for managing and picking Jackbox Party Pack games" />
|
||||||
|
|
||||||
|
<!-- Prevent flash of unstyled content in dark mode -->
|
||||||
|
<script>
|
||||||
|
// Initialize theme before page renders
|
||||||
|
const theme = localStorage.getItem('theme') ||
|
||||||
|
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||||
|
document.documentElement.classList.add(theme);
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export default {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
|
|||||||
25
frontend/public/favicon.svg
Normal file
25
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#6366f1;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#4f46e5;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Dice/Box shape -->
|
||||||
|
<rect x="10" y="10" width="80" height="80" rx="12" fill="url(#grad)"/>
|
||||||
|
|
||||||
|
<!-- Dots representing game selection -->
|
||||||
|
<circle cx="30" cy="30" r="6" fill="white" opacity="0.9"/>
|
||||||
|
<circle cx="50" cy="30" r="6" fill="white" opacity="0.9"/>
|
||||||
|
<circle cx="70" cy="30" r="6" fill="white" opacity="0.9"/>
|
||||||
|
|
||||||
|
<circle cx="30" cy="50" r="6" fill="white" opacity="0.9"/>
|
||||||
|
<circle cx="50" cy="50" r="6" fill="white" opacity="1"/>
|
||||||
|
<circle cx="70" cy="50" r="6" fill="white" opacity="0.9"/>
|
||||||
|
|
||||||
|
<circle cx="30" cy="70" r="6" fill="white" opacity="0.9"/>
|
||||||
|
<circle cx="50" cy="70" r="6" fill="white" opacity="0.9"/>
|
||||||
|
<circle cx="70" cy="70" r="6" fill="white" opacity="0.9"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 1021 B |
@@ -1,6 +1,10 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Routes, Route, Link } from 'react-router-dom';
|
import { Routes, Route, Link } from 'react-router-dom';
|
||||||
import { useAuth } from './context/AuthContext';
|
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 Home from './pages/Home';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import Picker from './pages/Picker';
|
import Picker from './pages/Picker';
|
||||||
@@ -9,36 +13,46 @@ import History from './pages/History';
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { isAuthenticated, logout } = useAuth();
|
const { isAuthenticated, logout } = useAuth();
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const closeMobileMenu = () => setMobileMenuOpen(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-100">
|
<ToastProvider>
|
||||||
|
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 transition-colors flex flex-col">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="bg-indigo-600 text-white shadow-lg">
|
<nav className="bg-indigo-600 dark:bg-indigo-800 text-white shadow-lg flex-shrink-0">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-2 sm:px-4">
|
||||||
<div className="flex justify-between items-center py-4">
|
<div className="flex justify-between items-center py-2 sm:py-3">
|
||||||
<Link to="/" className="text-2xl font-bold">
|
{/* Logo and Title */}
|
||||||
Jackbox Game Picker
|
<Link to="/" className="flex items-center gap-2 hover:opacity-90 transition min-w-0" onClick={closeMobileMenu}>
|
||||||
|
<Logo size="sm" className="flex-shrink-0" />
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="text-lg sm:text-xl font-bold">{branding.app.name}</div>
|
||||||
|
</div>
|
||||||
|
<div className="sm:hidden text-sm font-bold">JGP</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex gap-4 items-center">
|
{/* Desktop Navigation Links */}
|
||||||
<Link to="/" className="hover:text-indigo-200 transition">
|
<div className="hidden sm:flex gap-1 sm:gap-4 items-center text-sm sm:text-base">
|
||||||
|
<Link to="/" className="hover:text-indigo-200 dark:hover:text-indigo-300 transition px-2 py-1">
|
||||||
Home
|
Home
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/history" className="hover:text-indigo-200 transition">
|
<Link to="/history" className="hover:text-indigo-200 dark:hover:text-indigo-300 transition px-2 py-1">
|
||||||
History
|
History
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<>
|
<>
|
||||||
<Link to="/picker" className="hover:text-indigo-200 transition">
|
<Link to="/picker" className="hover:text-indigo-200 dark:hover:text-indigo-300 transition px-2 py-1">
|
||||||
Picker
|
Picker
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/manager" className="hover:text-indigo-200 transition">
|
<Link to="/manager" className="hover:text-indigo-200 dark:hover:text-indigo-300 transition px-2 py-1">
|
||||||
Manager
|
Manager
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
className="bg-indigo-700 hover:bg-indigo-800 px-4 py-2 rounded transition"
|
className="bg-indigo-700 dark:bg-indigo-900 hover:bg-indigo-800 dark:hover:bg-indigo-950 px-2 sm:px-4 py-1 sm:py-2 rounded transition text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
@@ -48,18 +62,99 @@ function App() {
|
|||||||
{!isAuthenticated && (
|
{!isAuthenticated && (
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to="/login"
|
||||||
className="bg-indigo-700 hover:bg-indigo-800 px-4 py-2 rounded transition"
|
className="bg-indigo-700 dark:bg-indigo-900 hover:bg-indigo-800 dark:hover:bg-indigo-950 px-2 sm:px-4 py-1 sm:py-2 rounded transition text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
Admin Login
|
Login
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Theme Toggle */}
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile: Theme Toggle and Hamburger */}
|
||||||
|
<div className="flex sm:hidden items-center gap-2">
|
||||||
|
<ThemeToggle />
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
|
className="p-2 hover:bg-indigo-700 dark:hover:bg-indigo-900 rounded transition"
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
{mobileMenuOpen ? (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
) : (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu Dropdown */}
|
||||||
|
{mobileMenuOpen && (
|
||||||
|
<div className="sm:hidden border-t border-indigo-700 dark:border-indigo-900 py-2">
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
onClick={closeMobileMenu}
|
||||||
|
className="px-4 py-2 hover:bg-indigo-700 dark:hover:bg-indigo-900 transition"
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/history"
|
||||||
|
onClick={closeMobileMenu}
|
||||||
|
className="px-4 py-2 hover:bg-indigo-700 dark:hover:bg-indigo-900 transition"
|
||||||
|
>
|
||||||
|
History
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{isAuthenticated && (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to="/picker"
|
||||||
|
onClick={closeMobileMenu}
|
||||||
|
className="px-4 py-2 hover:bg-indigo-700 dark:hover:bg-indigo-900 transition"
|
||||||
|
>
|
||||||
|
Picker
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/manager"
|
||||||
|
onClick={closeMobileMenu}
|
||||||
|
className="px-4 py-2 hover:bg-indigo-700 dark:hover:bg-indigo-900 transition"
|
||||||
|
>
|
||||||
|
Manager
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
logout();
|
||||||
|
closeMobileMenu();
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 text-left hover:bg-indigo-700 dark:hover:bg-indigo-900 transition"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isAuthenticated && (
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
onClick={closeMobileMenu}
|
||||||
|
className="px-4 py-2 hover:bg-indigo-700 dark:hover:bg-indigo-900 transition"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8 flex-grow">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
@@ -68,7 +163,22 @@ function App() {
|
|||||||
<Route path="/manager" element={<Manager />} />
|
<Route path="/manager" element={<Manager />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-12 flex-shrink-0">
|
||||||
|
<div className="container mx-auto px-4 py-6">
|
||||||
|
<div className="flex justify-between items-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<div>
|
||||||
|
{branding.app.name} v{branding.app.version}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{branding.app.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
</ToastProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
101
frontend/src/components/GamePoolModal.jsx
Normal file
101
frontend/src/components/GamePoolModal.jsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
function GamePoolModal({ games, onClose }) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
<div className="flex justify-between items-center p-4 sm:p-6 border-b dark:border-gray-700">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl sm:text-2xl font-bold dark:text-gray-100">Available Game Pool</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{games.length} {games.length === 1 ? 'game' : 'games'} match your filters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 text-2xl"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto p-4 sm:p-6">
|
||||||
|
{games.length === 0 ? (
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-center py-8">
|
||||||
|
No games match your current filters. Try adjusting the filters or enabling more games.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{games.map((game) => {
|
||||||
|
// Determine border color based on favor bias
|
||||||
|
const getBorderStyle = () => {
|
||||||
|
if (game.favor_bias === 1) {
|
||||||
|
return 'border-green-300 dark:border-green-700 bg-green-50 dark:bg-green-900/10';
|
||||||
|
}
|
||||||
|
if (game.favor_bias === -1) {
|
||||||
|
return 'border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-900/10';
|
||||||
|
}
|
||||||
|
return 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={game.id}
|
||||||
|
className={`border rounded-lg p-3 sm:p-4 ${getBorderStyle()}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
|
<h3 className="font-semibold text-sm sm:text-base dark:text-gray-100 flex-1">
|
||||||
|
{game.title}
|
||||||
|
</h3>
|
||||||
|
{/* Show favor indicator */}
|
||||||
|
{game.favor_bias === 1 && (
|
||||||
|
<span className="text-xs font-semibold bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-0.5 rounded whitespace-nowrap">
|
||||||
|
Favored
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{game.favor_bias === -1 && (
|
||||||
|
<span className="text-xs font-semibold bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 px-2 py-0.5 rounded whitespace-nowrap">
|
||||||
|
Disfavored
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
{game.pack_name}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
|
<span className="bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200 px-2 py-1 rounded">
|
||||||
|
{game.min_players}-{game.max_players} players
|
||||||
|
</span>
|
||||||
|
{game.game_type && (
|
||||||
|
<span className="bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 px-2 py-1 rounded">
|
||||||
|
{game.game_type}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{game.play_count > 0 && (
|
||||||
|
<span className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded">
|
||||||
|
{game.play_count} plays
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t dark:border-gray-700 p-4 sm:p-6">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-full bg-indigo-600 dark:bg-indigo-700 text-white py-2 sm:py-3 rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 transition"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GamePoolModal;
|
||||||
|
|
||||||
48
frontend/src/components/Logo.jsx
Normal file
48
frontend/src/components/Logo.jsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useTheme } from '../context/ThemeContext';
|
||||||
|
|
||||||
|
function Logo({ size = 'md', className = '' }) {
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'w-8 h-8',
|
||||||
|
md: 'w-10 h-10',
|
||||||
|
lg: 'w-16 h-16',
|
||||||
|
xl: 'w-24 h-24',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
className={`${sizes[size]} ${className}`}
|
||||||
|
aria-label="Jackbox Game Picker Logo"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="logo-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style={{ stopColor: '#6366f1', stopOpacity: 1 }} />
|
||||||
|
<stop offset="100%" style={{ stopColor: '#4f46e5', stopOpacity: 1 }} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Dice/Box shape */}
|
||||||
|
<rect x="10" y="10" width="80" height="80" rx="12" fill="url(#logo-grad)"/>
|
||||||
|
|
||||||
|
{/* Dots representing game selection */}
|
||||||
|
<circle cx="30" cy="30" r="6" fill="white" opacity="0.9"/>
|
||||||
|
<circle cx="50" cy="30" r="6" fill="white" opacity="0.9"/>
|
||||||
|
<circle cx="70" cy="30" r="6" fill="white" opacity="0.9"/>
|
||||||
|
|
||||||
|
<circle cx="30" cy="50" r="6" fill="white" opacity="0.9"/>
|
||||||
|
<circle cx="50" cy="50" r="6" fill="white" opacity="1"/>
|
||||||
|
<circle cx="70" cy="50" r="6" fill="white" opacity="0.9"/>
|
||||||
|
|
||||||
|
<circle cx="30" cy="70" r="6" fill="white" opacity="0.9"/>
|
||||||
|
<circle cx="50" cy="70" r="6" fill="white" opacity="0.9"/>
|
||||||
|
<circle cx="70" cy="70" r="6" fill="white" opacity="0.9"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Logo;
|
||||||
|
|
||||||
41
frontend/src/components/ThemeToggle.jsx
Normal file
41
frontend/src/components/ThemeToggle.jsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useTheme } from '../context/ThemeContext';
|
||||||
|
|
||||||
|
function ThemeToggle() {
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
|
const getTitle = () => {
|
||||||
|
if (theme === 'light') return 'Switch to dark mode';
|
||||||
|
if (theme === 'dark') return 'Switch to system mode';
|
||||||
|
return 'Switch to light mode';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="p-2 rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-900 transition-colors relative"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
title={getTitle()}
|
||||||
|
>
|
||||||
|
{theme === 'light' ? (
|
||||||
|
// Sun icon - currently light mode
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
) : theme === 'dark' ? (
|
||||||
|
// Moon icon - currently dark mode
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
// Computer/System icon - currently system mode
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7H5V5h10v7H8.771z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThemeToggle;
|
||||||
|
|
||||||
76
frontend/src/components/Toast.jsx
Normal file
76
frontend/src/components/Toast.jsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
const ToastContext = createContext();
|
||||||
|
|
||||||
|
export const useToast = () => {
|
||||||
|
const context = useContext(ToastContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useToast must be used within ToastProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ToastProvider = ({ children }) => {
|
||||||
|
const [toasts, setToasts] = useState([]);
|
||||||
|
|
||||||
|
const showToast = useCallback((message, type = 'info', duration = 4000) => {
|
||||||
|
const id = Date.now();
|
||||||
|
setToasts(prev => [...prev, { id, message, type }]);
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id));
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeToast = useCallback((id) => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const success = useCallback((message, duration) => showToast(message, 'success', duration), [showToast]);
|
||||||
|
const error = useCallback((message, duration) => showToast(message, 'error', duration), [showToast]);
|
||||||
|
const info = useCallback((message, duration) => showToast(message, 'info', duration), [showToast]);
|
||||||
|
const warning = useCallback((message, duration) => showToast(message, 'warning', duration), [showToast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ showToast, success, error, info, warning, removeToast }}>
|
||||||
|
{children}
|
||||||
|
<div className="fixed top-4 right-4 z-50 space-y-2 pointer-events-none">
|
||||||
|
{toasts.map(toast => (
|
||||||
|
<Toast key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Toast = ({ toast, onClose }) => {
|
||||||
|
const bgColors = {
|
||||||
|
success: 'bg-green-600 dark:bg-green-700',
|
||||||
|
error: 'bg-red-600 dark:bg-red-700',
|
||||||
|
warning: 'bg-orange-600 dark:bg-orange-700',
|
||||||
|
info: 'bg-indigo-600 dark:bg-indigo-700'
|
||||||
|
};
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
success: '✓',
|
||||||
|
error: '✕',
|
||||||
|
warning: '⚠',
|
||||||
|
info: 'ℹ'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${bgColors[toast.type]} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-md pointer-events-auto animate-slide-in`}>
|
||||||
|
<span className="text-xl font-bold flex-shrink-0">{icons[toast.type]}</span>
|
||||||
|
<span className="flex-1 text-sm">{toast.message}</span>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-shrink-0 hover:opacity-80 transition text-xl leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
18
frontend/src/config/branding.js
Normal file
18
frontend/src/config/branding.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export const branding = {
|
||||||
|
app: {
|
||||||
|
name: 'HSO Jackbox Game Picker',
|
||||||
|
shortName: 'HSO JGP',
|
||||||
|
version: '0.2.1',
|
||||||
|
description: 'Spicing up Hyper Spaceout game nights!',
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
author: 'cottongin',
|
||||||
|
keywords: 'hso, hyper spaceout, jackbox, party pack, game picker, multiplayer games',
|
||||||
|
themeColor: '#4F46E5', // Indigo-600
|
||||||
|
},
|
||||||
|
links: {
|
||||||
|
github: '', // Optional: Add your repo URL
|
||||||
|
support: 'cottongin@cottongin.xyz', // Optional: Add support/contact URL
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
70
frontend/src/context/ThemeContext.jsx
Normal file
70
frontend/src/context/ThemeContext.jsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import React, { createContext, useState, useContext, useEffect } from 'react';
|
||||||
|
|
||||||
|
const ThemeContext = createContext();
|
||||||
|
|
||||||
|
export const useTheme = () => {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useTheme must be used within a ThemeProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ThemeProvider = ({ children }) => {
|
||||||
|
const [theme, setTheme] = useState(() => {
|
||||||
|
// Check localStorage first, then system preference
|
||||||
|
const stored = localStorage.getItem('theme');
|
||||||
|
if (stored) return stored;
|
||||||
|
|
||||||
|
return 'system'; // Default to system preference
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = window.document.documentElement;
|
||||||
|
|
||||||
|
if (theme === 'system') {
|
||||||
|
// Follow system preference
|
||||||
|
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
root.classList.remove('light', 'dark');
|
||||||
|
root.classList.add(isDark ? 'dark' : 'light');
|
||||||
|
localStorage.setItem('theme', 'system');
|
||||||
|
} else {
|
||||||
|
// Use explicit theme
|
||||||
|
root.classList.remove('light', 'dark');
|
||||||
|
root.classList.add(theme);
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
}
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
// Listen for system theme changes when in system mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (theme !== 'system') return;
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const handleChange = () => {
|
||||||
|
const root = window.document.documentElement;
|
||||||
|
root.classList.remove('light', 'dark');
|
||||||
|
root.classList.add(mediaQuery.matches ? 'dark' : 'light');
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaQuery.addEventListener('change', handleChange);
|
||||||
|
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setTheme(prev => {
|
||||||
|
if (prev === 'light') return 'dark';
|
||||||
|
if (prev === 'dark') return 'system';
|
||||||
|
return 'light';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
theme,
|
||||||
|
toggleTheme,
|
||||||
|
isDark: theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches),
|
||||||
|
};
|
||||||
|
|
||||||
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -2,17 +2,9 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
body {
|
@layer base {
|
||||||
margin: 0;
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
@apply antialiased;
|
||||||
'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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ import ReactDOM from 'react-dom/client';
|
|||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import { AuthProvider } from './context/AuthContext';
|
import { AuthProvider } from './context/AuthContext';
|
||||||
|
import { ThemeProvider } from './context/ThemeContext';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<ThemeProvider>
|
||||||
<App />
|
<AuthProvider>
|
||||||
</AuthProvider>
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +1,50 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useToast } from '../components/Toast';
|
||||||
import api from '../api/axios';
|
import api from '../api/axios';
|
||||||
|
import { formatLocalDateTime, formatLocalDate, formatLocalTime } from '../utils/dateUtils';
|
||||||
|
|
||||||
function History() {
|
function History() {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
|
const { error, success } = useToast();
|
||||||
const [sessions, setSessions] = useState([]);
|
const [sessions, setSessions] = useState([]);
|
||||||
const [selectedSession, setSelectedSession] = useState(null);
|
const [selectedSession, setSelectedSession] = useState(null);
|
||||||
const [sessionGames, setSessionGames] = useState([]);
|
const [sessionGames, setSessionGames] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showChatImport, setShowChatImport] = useState(false);
|
const [showChatImport, setShowChatImport] = useState(false);
|
||||||
const [closingSession, setClosingSession] = useState(null);
|
const [closingSession, setClosingSession] = useState(null);
|
||||||
|
const [showAllSessions, setShowAllSessions] = useState(false);
|
||||||
|
const [deletingSession, setDeletingSession] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSessions();
|
loadSessions();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Auto-select active session if navigating from picker
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessions.length > 0 && !selectedSession) {
|
||||||
|
const activeSession = sessions.find(s => s.is_active === 1);
|
||||||
|
if (activeSession) {
|
||||||
|
loadSessionGames(activeSession.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [sessions]);
|
||||||
|
|
||||||
|
// Poll for updates on active session
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedSession) return;
|
||||||
|
|
||||||
|
const currentSession = sessions.find(s => s.id === selectedSession);
|
||||||
|
if (!currentSession || currentSession.is_active !== 1) return;
|
||||||
|
|
||||||
|
// Refresh games every 3 seconds for active session
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
loadSessionGames(selectedSession, true); // silent refresh
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [selectedSession, sessions]);
|
||||||
|
|
||||||
const loadSessions = async () => {
|
const loadSessions = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/sessions');
|
const response = await api.get('/sessions');
|
||||||
@@ -26,13 +56,17 @@ function History() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSessionGames = async (sessionId) => {
|
const loadSessionGames = async (sessionId, silent = false) => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/sessions/${sessionId}/games`);
|
const response = await api.get(`/sessions/${sessionId}/games`);
|
||||||
setSessionGames(response.data);
|
setSessionGames(response.data);
|
||||||
setSelectedSession(sessionId);
|
if (!silent) {
|
||||||
|
setSelectedSession(sessionId);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load session games', err);
|
if (!silent) {
|
||||||
|
console.error('Failed to load session games', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,74 +75,123 @@ function History() {
|
|||||||
await api.post(`/sessions/${sessionId}/close`, { notes });
|
await api.post(`/sessions/${sessionId}/close`, { notes });
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
setClosingSession(null);
|
setClosingSession(null);
|
||||||
|
if (selectedSession === sessionId) {
|
||||||
|
// Reload the session details to show updated state
|
||||||
|
loadSessionGames(sessionId);
|
||||||
|
}
|
||||||
|
success('Session ended successfully');
|
||||||
|
} catch (err) {
|
||||||
|
error('Failed to close session');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteSession = async (sessionId) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/sessions/${sessionId}`);
|
||||||
|
await loadSessions();
|
||||||
|
setDeletingSession(null);
|
||||||
if (selectedSession === sessionId) {
|
if (selectedSession === sessionId) {
|
||||||
setSelectedSession(null);
|
setSelectedSession(null);
|
||||||
setSessionGames([]);
|
setSessionGames([]);
|
||||||
}
|
}
|
||||||
|
success('Session deleted successfully');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to close session');
|
error('Failed to delete session: ' + (err.response?.data?.error || err.message));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-64">
|
<div className="flex justify-center items-center h-64">
|
||||||
<div className="text-xl text-gray-600">Loading...</div>
|
<div className="text-xl text-gray-600 dark:text-gray-400">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h1 className="text-4xl font-bold mb-8 text-gray-800">Session History</h1>
|
<h1 className="text-4xl font-bold mb-8 text-gray-800 dark:text-gray-100">Session History</h1>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-6">
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
{/* Sessions List */}
|
{/* Sessions List */}
|
||||||
<div className="md:col-span-1">
|
<div className="md:col-span-1">
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||||||
<h2 className="text-2xl font-semibold mb-4 text-gray-800">Sessions</h2>
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-100">Sessions</h2>
|
||||||
|
{sessions.length > 3 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAllSessions(!showAllSessions)}
|
||||||
|
className="text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 transition"
|
||||||
|
>
|
||||||
|
{showAllSessions ? 'Show Recent' : `Show All (${sessions.length})`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{sessions.length === 0 ? (
|
{sessions.length === 0 ? (
|
||||||
<p className="text-gray-500">No sessions found</p>
|
<p className="text-gray-500 dark:text-gray-400">No sessions found</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 max-h-[600px] overflow-y-auto">
|
<div className="space-y-1 max-h-[600px] overflow-y-auto">
|
||||||
{sessions.map(session => (
|
{(showAllSessions ? sessions : sessions.slice(0, 3)).map(session => (
|
||||||
<div
|
<div
|
||||||
key={session.id}
|
key={session.id}
|
||||||
onClick={() => loadSessionGames(session.id)}
|
className={`border rounded-lg transition ${
|
||||||
className={`p-4 border rounded-lg cursor-pointer transition ${
|
|
||||||
selectedSession === session.id
|
selectedSession === session.id
|
||||||
? 'border-indigo-500 bg-indigo-50'
|
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/30'
|
||||||
: 'border-gray-300 hover:border-indigo-300'
|
: 'border-gray-300 dark:border-gray-600 hover:border-indigo-300 dark:hover:border-indigo-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start mb-2">
|
{/* Main session info - clickable */}
|
||||||
<div className="font-semibold text-gray-800">
|
<div
|
||||||
Session #{session.id}
|
onClick={() => loadSessionGames(session.id)}
|
||||||
|
className="p-3 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-semibold text-sm text-gray-800 dark:text-gray-100">
|
||||||
|
Session #{session.id}
|
||||||
|
</span>
|
||||||
|
{session.is_active === 1 && (
|
||||||
|
<span className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs px-2 py-0.5 rounded flex-shrink-0">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>{formatLocalDate(session.created_at)}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{session.games_played} game{session.games_played !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{isAuthenticated && session.is_active === 1 && (
|
{/* Action buttons for authenticated users */}
|
||||||
<button
|
{isAuthenticated && (
|
||||||
onClick={(e) => {
|
<div className="px-3 pb-3 pt-0 flex gap-2">
|
||||||
e.stopPropagation();
|
{session.is_active === 1 ? (
|
||||||
setClosingSession(session.id);
|
<button
|
||||||
}}
|
onClick={(e) => {
|
||||||
className="mt-2 w-full bg-yellow-600 text-white px-3 py-1 rounded text-sm hover:bg-yellow-700 transition"
|
e.stopPropagation();
|
||||||
>
|
setClosingSession(session.id);
|
||||||
Close Session
|
}}
|
||||||
</button>
|
className="w-full bg-orange-600 dark:bg-orange-700 text-white px-4 py-2 rounded text-sm hover:bg-orange-700 dark:hover:bg-orange-800 transition"
|
||||||
|
>
|
||||||
|
End Session
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDeletingSession(session.id);
|
||||||
|
}}
|
||||||
|
className="w-full bg-red-600 dark:bg-red-700 text-white px-4 py-2 rounded text-sm hover:bg-red-700 dark:hover:bg-red-800 transition"
|
||||||
|
>
|
||||||
|
Delete Session
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -120,22 +203,34 @@ function History() {
|
|||||||
{/* Session Details */}
|
{/* Session Details */}
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
{selectedSession ? (
|
{selectedSession ? (
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-6">
|
||||||
<div className="flex justify-between items-start mb-6">
|
<div className="flex flex-col gap-4 mb-6">
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<h2 className="text-2xl font-semibold text-gray-800">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-2">
|
||||||
Session #{selectedSession}
|
<h2 className="text-xl sm:text-2xl font-semibold text-gray-800 dark:text-gray-100">
|
||||||
</h2>
|
Session #{selectedSession}
|
||||||
<p className="text-gray-600">
|
</h2>
|
||||||
|
{sessions.find(s => s.id === selectedSession)?.is_active === 1 && (
|
||||||
|
<span className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs sm:text-sm px-2 sm:px-3 py-1 rounded-full font-semibold animate-pulse inline-flex items-center gap-1 w-fit">
|
||||||
|
🟢 Active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm sm:text-base text-gray-600 dark:text-gray-400">
|
||||||
{sessions.find(s => s.id === selectedSession)?.created_at &&
|
{sessions.find(s => s.id === selectedSession)?.created_at &&
|
||||||
new Date(sessions.find(s => s.id === selectedSession).created_at).toLocaleString()}
|
formatLocalDateTime(sessions.find(s => s.id === selectedSession).created_at)}
|
||||||
</p>
|
</p>
|
||||||
|
{sessions.find(s => s.id === selectedSession)?.is_active === 1 && (
|
||||||
|
<p className="text-xs sm:text-sm text-gray-500 dark:text-gray-500 mt-1 italic">
|
||||||
|
Games update automatically
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isAuthenticated && sessions.find(s => s.id === selectedSession)?.is_active === 1 && (
|
{isAuthenticated && sessions.find(s => s.id === selectedSession)?.is_active === 1 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowChatImport(true)}
|
onClick={() => setShowChatImport(true)}
|
||||||
className="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition"
|
className="bg-indigo-600 dark:bg-indigo-700 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 transition text-sm sm:text-base w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
Import Chat Log
|
Import Chat Log
|
||||||
</button>
|
</button>
|
||||||
@@ -154,35 +249,35 @@ function History() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{sessionGames.length === 0 ? (
|
{sessionGames.length === 0 ? (
|
||||||
<p className="text-gray-500">No games played in this session</p>
|
<p className="text-gray-500 dark:text-gray-400">No games played in this session</p>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-semibold mb-4 text-gray-700">
|
<h3 className="text-xl font-semibold mb-4 text-gray-700 dark:text-gray-200">
|
||||||
Games Played ({sessionGames.length})
|
Games Played ({sessionGames.length})
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{sessionGames.map((game, index) => (
|
{sessionGames.map((game, index) => (
|
||||||
<div key={game.id} className="border border-gray-200 rounded-lg p-4">
|
<div key={game.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-gray-700/50">
|
||||||
<div className="flex justify-between items-start mb-2">
|
<div className="flex justify-between items-start mb-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-lg text-gray-800">
|
<div className="font-semibold text-lg text-gray-800 dark:text-gray-100">
|
||||||
{index + 1}. {game.title}
|
{index + 1}. {game.title}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-gray-600">{game.pack_name}</div>
|
<div className="text-gray-600 dark:text-gray-400">{game.pack_name}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{new Date(game.played_at).toLocaleTimeString()}
|
{formatLocalTime(game.played_at)}
|
||||||
</div>
|
</div>
|
||||||
{game.manually_added === 1 && (
|
{game.manually_added === 1 && (
|
||||||
<span className="inline-block mt-1 text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
|
<span className="inline-block mt-1 text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded">
|
||||||
Manual
|
Manual
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm text-gray-600">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold">Players:</span> {game.min_players}-{game.max_players}
|
<span className="font-semibold">Players:</span> {game.min_players}-{game.max_players}
|
||||||
</div>
|
</div>
|
||||||
@@ -191,7 +286,7 @@ function History() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold">Popularity:</span>{' '}
|
<span className="font-semibold">Popularity:</span>{' '}
|
||||||
<span className={game.popularity_score >= 0 ? 'text-green-600' : 'text-red-600'}>
|
<span className={game.popularity_score >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
|
||||||
{game.popularity_score > 0 ? '+' : ''}{game.popularity_score}
|
{game.popularity_score > 0 ? '+' : ''}{game.popularity_score}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,41 +298,105 @@ function History() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6 flex items-center justify-center h-64">
|
<div className="bg-white dark:bg-gray-800 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>
|
<p className="text-gray-500 dark:text-gray-400 text-lg">Select a session to view details</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Close Session Modal */}
|
{/* End Session Modal */}
|
||||||
{closingSession && (
|
{closingSession && (
|
||||||
<CloseSessionModal
|
<EndSessionModal
|
||||||
sessionId={closingSession}
|
sessionId={closingSession}
|
||||||
|
sessionGames={closingSession === selectedSession ? sessionGames : []}
|
||||||
onClose={() => setClosingSession(null)}
|
onClose={() => setClosingSession(null)}
|
||||||
onConfirm={handleCloseSession}
|
onConfirm={handleCloseSession}
|
||||||
|
onShowChatImport={() => {
|
||||||
|
setShowChatImport(true);
|
||||||
|
if (closingSession !== selectedSession) {
|
||||||
|
loadSessionGames(closingSession);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{deletingSession && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-8 max-w-md w-full">
|
||||||
|
<h2 className="text-2xl font-bold mb-4 text-red-600 dark:text-red-400">Delete Session?</h2>
|
||||||
|
<p className="text-gray-700 dark:text-gray-300 mb-6">
|
||||||
|
Are you sure you want to delete Session #{deletingSession}?
|
||||||
|
This will permanently delete all games and chat logs associated with this session. This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteSession(deletingSession)}
|
||||||
|
className="flex-1 bg-red-600 dark:bg-red-700 text-white py-3 rounded-lg hover:bg-red-700 dark:hover:bg-red-800 transition font-semibold"
|
||||||
|
>
|
||||||
|
Delete Permanently
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeletingSession(null)}
|
||||||
|
className="flex-1 bg-gray-600 dark:bg-gray-700 text-white py-3 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CloseSessionModal({ sessionId, onClose, onConfirm }) {
|
function EndSessionModal({ sessionId, sessionGames, onClose, onConfirm, onShowChatImport }) {
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
|
|
||||||
|
// Check if any games have been voted on (popularity != 0)
|
||||||
|
const hasPopularityData = sessionGames.some(game => game.popularity_score !== 0);
|
||||||
|
const showPopularityWarning = sessionGames.length > 0 && !hasPopularityData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white rounded-lg p-8 max-w-md w-full">
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-8 max-w-md w-full">
|
||||||
<h2 className="text-2xl font-bold mb-4">Close Session #{sessionId}</h2>
|
<h2 className="text-2xl font-bold mb-4 dark:text-gray-100">End Session #{sessionId}</h2>
|
||||||
|
|
||||||
|
{/* Popularity Warning */}
|
||||||
|
{showPopularityWarning && (
|
||||||
|
<div className="mb-4 p-4 bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-300 dark:border-yellow-700 rounded-lg">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-yellow-600 dark:text-yellow-400 text-xl">⚠️</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-semibold text-yellow-800 dark:text-yellow-200 mb-1">
|
||||||
|
No Popularity Data
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-yellow-700 dark:text-yellow-300 mb-3">
|
||||||
|
You haven't imported chat reactions yet. Import now to track which games your players loved!
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
onShowChatImport();
|
||||||
|
}}
|
||||||
|
className="text-sm bg-yellow-600 dark:bg-yellow-700 text-white px-4 py-2 rounded hover:bg-yellow-700 dark:hover:bg-yellow-800 transition"
|
||||||
|
>
|
||||||
|
Import Chat Log
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-gray-700 font-semibold mb-2">
|
<label className="block text-gray-700 dark:text-gray-300 font-semibold mb-2">
|
||||||
Session Notes (optional)
|
Session Notes (optional)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={notes}
|
value={notes}
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg h-32"
|
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg h-32 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||||
placeholder="Add any notes about this session..."
|
placeholder="Add any notes about this session..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,13 +404,13 @@ function CloseSessionModal({ sessionId, onClose, onConfirm }) {
|
|||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => onConfirm(sessionId, notes)}
|
onClick={() => onConfirm(sessionId, notes)}
|
||||||
className="flex-1 bg-indigo-600 text-white py-3 rounded-lg hover:bg-indigo-700 transition"
|
className="flex-1 bg-orange-600 dark:bg-orange-700 text-white py-3 rounded-lg hover:bg-orange-700 dark:hover:bg-orange-800 transition"
|
||||||
>
|
>
|
||||||
Close Session
|
End Session
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex-1 bg-gray-600 text-white py-3 rounded-lg hover:bg-gray-700 transition"
|
className="flex-1 bg-gray-600 dark:bg-gray-700 text-white py-3 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -265,10 +424,11 @@ function ChatImportPanel({ sessionId, onClose, onImportComplete }) {
|
|||||||
const [chatData, setChatData] = useState('');
|
const [chatData, setChatData] = useState('');
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
|
const { error, success } = useToast();
|
||||||
|
|
||||||
const handleImport = async () => {
|
const handleImport = async () => {
|
||||||
if (!chatData.trim()) {
|
if (!chatData.trim()) {
|
||||||
alert('Please enter chat data');
|
error('Please enter chat data');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,45 +441,46 @@ function ChatImportPanel({ sessionId, onClose, onImportComplete }) {
|
|||||||
chatData: parsedData
|
chatData: parsedData
|
||||||
});
|
});
|
||||||
setResult(response.data);
|
setResult(response.data);
|
||||||
|
success('Chat log imported successfully');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onImportComplete();
|
onImportComplete();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Import failed: ' + (err.response?.data?.error || err.message));
|
error('Import failed: ' + (err.response?.data?.error || err.message));
|
||||||
} finally {
|
} finally {
|
||||||
setImporting(false);
|
setImporting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-50 border border-gray-300 rounded-lg p-6 mb-6">
|
<div className="bg-gray-50 dark:bg-gray-700/50 border border-gray-300 dark:border-gray-600 rounded-lg p-6 mb-6">
|
||||||
<h3 className="text-xl font-semibold mb-4">Import Chat Log</h3>
|
<h3 className="text-xl font-semibold mb-4 dark:text-gray-100">Import Chat Log</h3>
|
||||||
|
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
Paste JSON array with format: [{"{"}"username": "...", "message": "...", "timestamp": "..."{"}"}]
|
Paste JSON array with format: [{"{"}"username": "...", "message": "...", "timestamp": "..."{"}"}]
|
||||||
<br />
|
<br />
|
||||||
The system will detect "thisgame++" and "thisgame--" patterns and update game popularity.
|
The system will detect "thisgame++" and "thisgame--" patterns and update game popularity.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-gray-700 font-semibold mb-2">Chat JSON Data</label>
|
<label className="block text-gray-700 dark:text-gray-300 font-semibold mb-2">Chat JSON Data</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={chatData}
|
value={chatData}
|
||||||
onChange={(e) => setChatData(e.target.value)}
|
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"
|
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg h-48 font-mono text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||||
placeholder='[{"username":"Alice","message":"thisgame++","timestamp":"2024-01-01T12:00:00Z"}]'
|
placeholder='[{"username":"Alice","message":"thisgame++","timestamp":"2024-01-01T12:00:00Z"}]'
|
||||||
disabled={importing}
|
disabled={importing}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div className="mb-4 p-4 bg-green-50 border border-green-300 rounded-lg">
|
<div className="mb-4 p-4 bg-green-50 dark:bg-green-900/30 border border-green-300 dark:border-green-700 rounded-lg">
|
||||||
<p className="font-semibold text-green-800">Import Successful!</p>
|
<p className="font-semibold text-green-800 dark:text-green-200">Import Successful!</p>
|
||||||
<p className="text-sm text-green-700">
|
<p className="text-sm text-green-700 dark:text-green-300">
|
||||||
Imported {result.messagesImported} messages, processed {result.votesProcessed} votes
|
Imported {result.messagesImported} messages, processed {result.votesProcessed} votes
|
||||||
</p>
|
</p>
|
||||||
{result.votesByGame && Object.keys(result.votesByGame).length > 0 && (
|
{result.votesByGame && Object.keys(result.votesByGame).length > 0 && (
|
||||||
<div className="mt-2 text-sm">
|
<div className="mt-2 text-sm text-green-700 dark:text-green-300">
|
||||||
<p className="font-semibold">Votes by game:</p>
|
<p className="font-semibold">Votes by game:</p>
|
||||||
<ul className="list-disc list-inside">
|
<ul className="list-disc list-inside">
|
||||||
{Object.values(result.votesByGame).map((vote, i) => (
|
{Object.values(result.votesByGame).map((vote, i) => (
|
||||||
@@ -337,13 +498,13 @@ function ChatImportPanel({ sessionId, onClose, onImportComplete }) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleImport}
|
onClick={handleImport}
|
||||||
disabled={importing}
|
disabled={importing}
|
||||||
className="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition disabled:bg-gray-400"
|
className="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition disabled:bg-gray-400 dark:disabled:bg-gray-600"
|
||||||
>
|
>
|
||||||
{importing ? 'Importing...' : 'Import'}
|
{importing ? 'Importing...' : 'Import'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="bg-gray-600 text-white px-6 py-2 rounded-lg hover:bg-gray-700 transition"
|
className="bg-gray-600 dark:bg-gray-700 text-white px-6 py-2 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition"
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import api from '../api/axios';
|
import api from '../api/axios';
|
||||||
|
import { formatLocalDateTime, formatLocalTime } from '../utils/dateUtils';
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
@@ -13,14 +14,28 @@ function Home() {
|
|||||||
loadActiveSession();
|
loadActiveSession();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Auto-refresh for active session
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeSession) return;
|
||||||
|
|
||||||
|
// Refresh games every 3 seconds for active session
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
loadSessionGames(activeSession.id, true); // silent refresh
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [activeSession]);
|
||||||
|
|
||||||
const loadActiveSession = async () => {
|
const loadActiveSession = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/sessions/active');
|
const response = await api.get('/sessions/active');
|
||||||
setActiveSession(response.data);
|
|
||||||
|
|
||||||
if (response.data?.id) {
|
// Handle both old format (direct session) and new format (session: null)
|
||||||
const gamesResponse = await api.get(`/sessions/${response.data.id}/games`);
|
const session = response.data?.session !== undefined ? response.data.session : response.data;
|
||||||
setSessionGames(gamesResponse.data);
|
setActiveSession(session);
|
||||||
|
|
||||||
|
if (session?.id) {
|
||||||
|
await loadSessionGames(session.id);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// No active session is okay
|
// No active session is okay
|
||||||
@@ -30,38 +45,47 @@ function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadSessionGames = async (sessionId, silent = false) => {
|
||||||
|
try {
|
||||||
|
const gamesResponse = await api.get(`/sessions/${sessionId}/games`);
|
||||||
|
// Reverse chronological order (most recent first)
|
||||||
|
setSessionGames(gamesResponse.data.reverse());
|
||||||
|
} catch (error) {
|
||||||
|
if (!silent) {
|
||||||
|
console.error('Failed to load session games', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-64">
|
<div className="flex justify-center items-center h-64">
|
||||||
<div className="text-xl text-gray-600">Loading...</div>
|
<div className="text-xl text-gray-600 dark:text-gray-400">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<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 ? (
|
{activeSession ? (
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-8">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-6 mb-8">
|
||||||
<div className="flex justify-between items-start mb-4">
|
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-4 mb-4">
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<h2 className="text-2xl font-semibold text-green-600 mb-2">
|
<h2 className="text-xl sm:text-2xl font-semibold text-green-600 dark:text-green-400 mb-2">
|
||||||
Live Session Active
|
Live Session Active
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600">
|
<p className="text-sm sm:text-base text-gray-600 dark:text-gray-400">
|
||||||
Started: {new Date(activeSession.created_at).toLocaleString()}
|
Started: {formatLocalDateTime(activeSession.created_at)}
|
||||||
</p>
|
</p>
|
||||||
{activeSession.notes && (
|
{activeSession.notes && (
|
||||||
<p className="text-gray-600 mt-2">Notes: {activeSession.notes}</p>
|
<p className="text-sm sm:text-base text-gray-600 dark:text-gray-400 mt-2">Notes: {activeSession.notes}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<Link
|
<Link
|
||||||
to="/picker"
|
to="/picker"
|
||||||
className="bg-indigo-600 text-white px-6 py-3 rounded-lg hover:bg-indigo-700 transition"
|
className="bg-indigo-600 dark:bg-indigo-700 text-white px-6 py-3 rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 transition text-center sm:text-left whitespace-nowrap"
|
||||||
>
|
>
|
||||||
Pick a Game
|
Pick a Game
|
||||||
</Link>
|
</Link>
|
||||||
@@ -70,50 +94,79 @@ function Home() {
|
|||||||
|
|
||||||
{sessionGames.length > 0 && (
|
{sessionGames.length > 0 && (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<h3 className="text-xl font-semibold mb-4">Games Played This Session</h3>
|
<h3 className="text-lg sm:text-xl font-semibold mb-4 text-gray-800 dark:text-gray-100">Games Played This Session</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{sessionGames.map((game, index) => (
|
{sessionGames.map((game, index) => {
|
||||||
|
const displayIndex = sessionGames.length - index;
|
||||||
|
const isPlaying = game.status === 'playing';
|
||||||
|
const isSkipped = game.status === 'skipped';
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={game.id}
|
key={game.id}
|
||||||
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg"
|
className={`flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 p-3 sm:p-4 rounded-lg transition ${
|
||||||
|
isPlaying
|
||||||
|
? 'bg-green-50 dark:bg-green-900/20 border-2 border-green-300 dark:border-green-700'
|
||||||
|
: isSkipped
|
||||||
|
? 'bg-gray-100 dark:bg-gray-700/50'
|
||||||
|
: 'bg-gray-50 dark:bg-gray-700'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<span className="font-semibold text-gray-700">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{index + 1}. {game.title}
|
<span className={`font-semibold text-sm sm:text-base break-words ${
|
||||||
</span>
|
isSkipped
|
||||||
<span className="text-gray-500 ml-2">({game.pack_name})</span>
|
? 'text-gray-500 dark:text-gray-500 line-through'
|
||||||
{game.manually_added === 1 && (
|
: 'text-gray-700 dark:text-gray-200'
|
||||||
<span className="ml-2 text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
|
}`}>
|
||||||
Manual
|
{displayIndex}. {game.title}
|
||||||
</span>
|
</span>
|
||||||
)}
|
{isPlaying && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded font-semibold">
|
||||||
|
🎮 Playing
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isSkipped && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 px-2 py-1 rounded">
|
||||||
|
⏭️ Skipped
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs sm:text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
({game.pack_name})
|
||||||
|
{game.manually_added === 1 && (
|
||||||
|
<span className="ml-2 text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded">
|
||||||
|
Manual
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-xs sm:text-sm text-gray-500 dark:text-gray-400 flex-shrink-0">
|
||||||
{new Date(game.played_at).toLocaleTimeString()}
|
{formatLocalTime(game.played_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-8">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-8">
|
||||||
<h2 className="text-2xl font-semibold text-gray-700 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-4">
|
||||||
No Active Session
|
No Active Session
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
There is currently no game session in progress.
|
There is currently no game session in progress.
|
||||||
</p>
|
</p>
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<Link
|
<Link
|
||||||
to="/picker"
|
to="/picker"
|
||||||
className="inline-block bg-indigo-600 text-white px-6 py-3 rounded-lg hover:bg-indigo-700 transition"
|
className="inline-block bg-indigo-600 dark:bg-indigo-700 text-white px-6 py-3 rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 transition"
|
||||||
>
|
>
|
||||||
Start a New Session
|
Start a New Session
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-500">
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
Admin access required to start a new session.
|
Admin access required to start a new session.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -123,12 +176,12 @@ function Home() {
|
|||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
<Link
|
<Link
|
||||||
to="/history"
|
to="/history"
|
||||||
className="bg-white rounded-lg shadow-lg p-6 hover:shadow-xl transition"
|
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 hover:shadow-xl transition"
|
||||||
>
|
>
|
||||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">
|
<h3 className="text-xl font-semibold text-gray-800 dark:text-gray-100 mb-2">
|
||||||
Session History
|
Session History
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600 dark:text-gray-300">
|
||||||
View past gaming sessions and the games that were played
|
View past gaming sessions and the games that were played
|
||||||
</p>
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -136,12 +189,12 @@ function Home() {
|
|||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<Link
|
<Link
|
||||||
to="/manager"
|
to="/manager"
|
||||||
className="bg-white rounded-lg shadow-lg p-6 hover:shadow-xl transition"
|
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 hover:shadow-xl transition"
|
||||||
>
|
>
|
||||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">
|
<h3 className="text-xl font-semibold text-gray-800 dark:text-gray-100 mb-2">
|
||||||
Game Manager
|
Game Manager
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600 dark:text-gray-300">
|
||||||
Manage games, packs, and view statistics
|
Manage games, packs, and view statistics
|
||||||
</p>
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ function Login() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto">
|
<div className="max-w-md mx-auto">
|
||||||
<div className="bg-white rounded-lg shadow-lg p-8">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
|
||||||
<h1 className="text-3xl font-bold mb-6 text-gray-800">Admin Login</h1>
|
<h1 className="text-3xl font-bold mb-6 text-gray-800 dark:text-gray-100">Admin Login</h1>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label htmlFor="key" className="block text-gray-700 font-semibold mb-2">
|
<label htmlFor="key" className="block text-gray-700 dark:text-gray-300 font-semibold mb-2">
|
||||||
Admin Key
|
Admin Key
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -45,7 +45,7 @@ function Login() {
|
|||||||
id="key"
|
id="key"
|
||||||
value={key}
|
value={key}
|
||||||
onChange={(e) => setKey(e.target.value)}
|
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"
|
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||||
placeholder="Enter admin key"
|
placeholder="Enter admin key"
|
||||||
required
|
required
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -53,7 +53,7 @@ function Login() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
|
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-200 rounded">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -61,13 +61,13 @@ function Login() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
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"
|
className="w-full bg-indigo-600 text-white py-3 rounded-lg hover:bg-indigo-700 transition disabled:bg-gray-400 dark:disabled:bg-gray-600 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{loading ? 'Logging in...' : 'Login'}
|
{loading ? 'Logging in...' : 'Login'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="mt-4 text-sm text-gray-600 text-center">
|
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400 text-center">
|
||||||
Admin privileges are required to manage games and sessions
|
Admin privileges are required to manage games and sessions
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,11 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import api from '../api/axios';
|
import api from '../api/axios';
|
||||||
|
import GamePoolModal from '../components/GamePoolModal';
|
||||||
|
import { formatLocalTime } from '../utils/dateUtils';
|
||||||
|
|
||||||
function Picker() {
|
function Picker() {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated, loading: authLoading } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [activeSession, setActiveSession] = useState(null);
|
const [activeSession, setActiveSession] = useState(null);
|
||||||
@@ -23,26 +25,48 @@ function Picker() {
|
|||||||
// Manual game selection
|
// Manual game selection
|
||||||
const [showManualSelect, setShowManualSelect] = useState(false);
|
const [showManualSelect, setShowManualSelect] = useState(false);
|
||||||
const [manualGameId, setManualGameId] = useState('');
|
const [manualGameId, setManualGameId] = useState('');
|
||||||
|
|
||||||
|
// Game pool viewer
|
||||||
|
const [showGamePool, setShowGamePool] = useState(false);
|
||||||
|
const [eligibleGames, setEligibleGames] = useState([]);
|
||||||
|
|
||||||
|
// Trigger to refresh session games list
|
||||||
|
const [gamesUpdateTrigger, setGamesUpdateTrigger] = useState(0);
|
||||||
|
|
||||||
|
// Mobile filters toggle
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
|
||||||
|
// Exclude previously played games
|
||||||
|
const [excludePlayedGames, setExcludePlayedGames] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Wait for auth to finish loading before checking authentication
|
||||||
|
if (authLoading) return;
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadData();
|
loadData();
|
||||||
}, [isAuthenticated, navigate]);
|
}, [isAuthenticated, authLoading, navigate]);
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
// Load active session or create one
|
// Load active session or create one
|
||||||
try {
|
const sessionResponse = await api.get('/sessions/active');
|
||||||
const sessionResponse = await api.get('/sessions/active');
|
|
||||||
setActiveSession(sessionResponse.data);
|
// Handle new format { session: null } or old format (direct session object)
|
||||||
} catch (err) {
|
let session = sessionResponse.data?.session !== undefined
|
||||||
// No active session, create one
|
? sessionResponse.data.session
|
||||||
|
: sessionResponse.data;
|
||||||
|
|
||||||
|
// If no active session, create one
|
||||||
|
if (!session || !session.id) {
|
||||||
const newSession = await api.post('/sessions', {});
|
const newSession = await api.post('/sessions', {});
|
||||||
setActiveSession(newSession.data);
|
session = newSession.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setActiveSession(session);
|
||||||
|
|
||||||
// Load all games for manual selection
|
// Load all games for manual selection
|
||||||
const gamesResponse = await api.get('/games');
|
const gamesResponse = await api.get('/games');
|
||||||
@@ -54,6 +78,50 @@ function Picker() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadEligibleGames = async () => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('enabled', 'true');
|
||||||
|
|
||||||
|
if (playerCount) {
|
||||||
|
params.append('playerCount', playerCount);
|
||||||
|
}
|
||||||
|
if (drawingFilter !== 'both') {
|
||||||
|
params.append('drawing', drawingFilter);
|
||||||
|
}
|
||||||
|
if (lengthFilter) {
|
||||||
|
params.append('length', lengthFilter);
|
||||||
|
}
|
||||||
|
if (familyFriendlyFilter) {
|
||||||
|
params.append('familyFriendly', familyFriendlyFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
let games = await api.get(`/games?${params.toString()}`);
|
||||||
|
let eligibleGamesList = games.data;
|
||||||
|
|
||||||
|
// Apply session-based exclusions if needed
|
||||||
|
if (activeSession && excludePlayedGames) {
|
||||||
|
// Get all played games in this session
|
||||||
|
const sessionGamesResponse = await api.get(`/sessions/${activeSession.id}/games`);
|
||||||
|
const playedGameIds = sessionGamesResponse.data.map(g => g.game_id);
|
||||||
|
|
||||||
|
// Filter out played games
|
||||||
|
eligibleGamesList = eligibleGamesList.filter(game => !playedGameIds.includes(game.id));
|
||||||
|
} else if (activeSession) {
|
||||||
|
// Default behavior: exclude last 2 games
|
||||||
|
const sessionGamesResponse = await api.get(`/sessions/${activeSession.id}/games`);
|
||||||
|
const recentGames = sessionGamesResponse.data.slice(-2);
|
||||||
|
const recentGameIds = recentGames.map(g => g.game_id);
|
||||||
|
|
||||||
|
eligibleGamesList = eligibleGamesList.filter(game => !recentGameIds.includes(game.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
setEligibleGames(eligibleGamesList);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load eligible games', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handlePickGame = async () => {
|
const handlePickGame = async () => {
|
||||||
if (!activeSession) return;
|
if (!activeSession) return;
|
||||||
|
|
||||||
@@ -66,7 +134,8 @@ function Picker() {
|
|||||||
playerCount: playerCount ? parseInt(playerCount) : undefined,
|
playerCount: playerCount ? parseInt(playerCount) : undefined,
|
||||||
drawing: drawingFilter !== 'both' ? drawingFilter : undefined,
|
drawing: drawingFilter !== 'both' ? drawingFilter : undefined,
|
||||||
length: lengthFilter || undefined,
|
length: lengthFilter || undefined,
|
||||||
familyFriendly: familyFriendlyFilter ? familyFriendlyFilter === 'yes' : undefined
|
familyFriendly: familyFriendlyFilter ? familyFriendlyFilter === 'yes' : undefined,
|
||||||
|
excludePlayed: excludePlayedGames
|
||||||
});
|
});
|
||||||
|
|
||||||
setSelectedGame(response.data.game);
|
setSelectedGame(response.data.game);
|
||||||
@@ -87,8 +156,8 @@ function Picker() {
|
|||||||
manually_added: false
|
manually_added: false
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reload data
|
// Trigger games list refresh
|
||||||
await loadData();
|
setGamesUpdateTrigger(prev => prev + 1);
|
||||||
setSelectedGame(null);
|
setSelectedGame(null);
|
||||||
setError('');
|
setError('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -105,8 +174,8 @@ function Picker() {
|
|||||||
manually_added: true
|
manually_added: true
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reload data
|
// Trigger games list refresh
|
||||||
await loadData();
|
setGamesUpdateTrigger(prev => prev + 1);
|
||||||
setManualGameId('');
|
setManualGameId('');
|
||||||
setShowManualSelect(false);
|
setShowManualSelect(false);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -115,10 +184,59 @@ function Picker() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
const handleSelectVersion = async (gameId) => {
|
||||||
|
if (!activeSession) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post(`/sessions/${activeSession.id}/games`, {
|
||||||
|
game_id: gameId,
|
||||||
|
manually_added: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger games list refresh
|
||||||
|
setGamesUpdateTrigger(prev => prev + 1);
|
||||||
|
setSelectedGame(null);
|
||||||
|
setError('');
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to add game to session');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find similar versions of a game based on title patterns
|
||||||
|
const findSimilarVersions = (game) => {
|
||||||
|
if (!game) return [];
|
||||||
|
|
||||||
|
// Extract base name by removing common version patterns
|
||||||
|
const baseName = game.title
|
||||||
|
.replace(/\s*\d+$/, '') // Remove trailing numbers (e.g., "Game 2" -> "Game")
|
||||||
|
.replace(/\s*:\s*.*$/, '') // Remove subtitle after colon
|
||||||
|
.replace(/\s*\(.*?\)$/, '') // Remove parenthetical
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Find games with similar base names (but not the exact same game)
|
||||||
|
return allGames.filter(g => {
|
||||||
|
if (g.id === game.id) return false; // Exclude the current game
|
||||||
|
if (!g.enabled) return false; // Only show enabled games
|
||||||
|
|
||||||
|
const otherBaseName = g.title
|
||||||
|
.replace(/\s*\d+$/, '')
|
||||||
|
.replace(/\s*:\s*.*$/, '')
|
||||||
|
.replace(/\s*\(.*?\)$/, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Match if base names are the same (case insensitive)
|
||||||
|
return otherBaseName.toLowerCase() === baseName.toLowerCase();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const similarVersions = React.useMemo(() => {
|
||||||
|
return findSimilarVersions(selectedGame);
|
||||||
|
}, [selectedGame, allGames]);
|
||||||
|
|
||||||
|
if (authLoading || loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-64">
|
<div className="flex justify-center items-center h-64">
|
||||||
<div className="text-xl text-gray-600">Loading...</div>
|
<div className="text-xl text-gray-600 dark:text-gray-400">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -126,7 +244,7 @@ function Picker() {
|
|||||||
if (!activeSession) {
|
if (!activeSession) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
<div className="bg-red-100 border border-red-400 text-red-700 p-4 rounded">
|
<div className="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-200 p-4 rounded">
|
||||||
Failed to load or create session. Please try again.
|
Failed to load or create session. Please try again.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,141 +253,298 @@ function Picker() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<h1 className="text-4xl font-bold mb-8 text-gray-800">Game Picker</h1>
|
<h1 className="text-2xl sm:text-4xl font-bold mb-4 sm:mb-8 text-gray-800 dark:text-gray-100">Game Picker</h1>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-6">
|
<div className="grid md:grid-cols-3 gap-4 sm:gap-6">
|
||||||
{/* Filters Panel */}
|
{/* Picker Controls Panel */}
|
||||||
<div className="md:col-span-1">
|
<div className="md:col-span-1 space-y-4">
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
{/* Main Action Buttons - Above filters on mobile */}
|
||||||
<h2 className="text-2xl font-semibold mb-4 text-gray-800">Filters</h2>
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4">
|
||||||
|
<button
|
||||||
<div className="space-y-4">
|
onClick={handlePickGame}
|
||||||
<div>
|
disabled={picking}
|
||||||
<label className="block text-gray-700 font-semibold mb-2">
|
className="w-full bg-indigo-600 text-white py-4 rounded-lg hover:bg-indigo-700 transition disabled:bg-gray-400 dark:disabled:bg-gray-600 disabled:cursor-not-allowed font-bold text-xl mb-3"
|
||||||
Player Count
|
>
|
||||||
</label>
|
{picking ? 'Rolling...' : '🎲 Roll the Dice'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Exclude played games checkbox */}
|
||||||
|
<label className="flex items-center gap-3 p-3 mb-4 cursor-pointer group bg-gray-50 dark:bg-gray-700/30 hover:bg-gray-100 dark:hover:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 transition">
|
||||||
|
<div className="relative flex items-center">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="checkbox"
|
||||||
min="1"
|
checked={excludePlayedGames}
|
||||||
max="100"
|
onChange={(e) => setExcludePlayedGames(e.target.checked)}
|
||||||
value={playerCount}
|
className="w-5 h-5 rounded border-2 border-gray-300 dark:border-gray-500 text-indigo-600 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 bg-white dark:bg-gray-800 cursor-pointer transition checked:border-indigo-600 dark:checked:border-indigo-500 checked:bg-indigo-600 dark:checked:bg-indigo-600"
|
||||||
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>
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 transition select-none flex-1">
|
||||||
<div>
|
Only select from unplayed games
|
||||||
<label className="block text-gray-700 font-semibold mb-2">
|
</span>
|
||||||
Drawing Games
|
</label>
|
||||||
</label>
|
|
||||||
<select
|
<div className="space-y-4 sm:space-y-2">
|
||||||
value={drawingFilter}
|
<div className="grid grid-cols-2 gap-4 sm:gap-2">
|
||||||
onChange={(e) => setDrawingFilter(e.target.value)}
|
<button
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
onClick={() => setShowManualSelect(!showManualSelect)}
|
||||||
|
className="bg-gray-600 dark:bg-gray-700 text-white py-3 sm:py-2 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
<option value="both">Both</option>
|
{showManualSelect ? 'Cancel' : 'Manual'}
|
||||||
<option value="only">Only Drawing</option>
|
</button>
|
||||||
<option value="exclude">No Drawing</option>
|
|
||||||
</select>
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await loadEligibleGames();
|
||||||
|
setShowGamePool(true);
|
||||||
|
}}
|
||||||
|
className="bg-blue-600 dark:bg-blue-700 text-white py-3 sm:py-2 rounded-lg hover:bg-blue-700 dark:hover:bg-blue-800 transition text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
View Pool
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/history')}
|
||||||
|
className="w-full bg-indigo-300 dark:bg-indigo-400 text-gray-900 dark:text-gray-900 py-3 sm:py-2 rounded-lg hover:bg-indigo-400 dark:hover:bg-indigo-500 transition text-xs sm:text-sm font-medium"
|
||||||
|
>
|
||||||
|
Go to Session Manager
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden">
|
||||||
|
{/* Mobile: Collapsible header */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
|
className="w-full md:hidden flex justify-between items-center p-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700/50 transition"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Filters {playerCount || lengthFilter || drawingFilter !== 'both' || familyFriendlyFilter ? '(Active)' : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
{showFilters ? '▼' : '▶'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Desktop: Always show title */}
|
||||||
|
<div className="hidden md:block p-4 pb-0">
|
||||||
|
<h2 className="text-lg font-semibold mb-3 text-gray-800 dark:text-gray-100">Filters</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter content - collapsible on mobile */}
|
||||||
|
<div className={`${showFilters ? 'block' : 'hidden'} md:block p-4 space-y-3`}>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-700 dark:text-gray-300 font-semibold mb-1">
|
||||||
|
Player Count
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const current = parseInt(playerCount) || 0;
|
||||||
|
if (current > 1) {
|
||||||
|
setPlayerCount(String(current - 1));
|
||||||
|
} else if (!playerCount) {
|
||||||
|
setPlayerCount('3');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-10 h-10 sm:w-8 sm:h-8 flex items-center justify-center bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg transition font-bold text-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled={playerCount && parseInt(playerCount) <= 1}
|
||||||
|
title="Decrease"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={playerCount}
|
||||||
|
onChange={(e) => setPlayerCount(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 pr-8 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
placeholder="Any"
|
||||||
|
/>
|
||||||
|
{playerCount && (
|
||||||
|
<button
|
||||||
|
onClick={() => setPlayerCount('')}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition"
|
||||||
|
title="Clear filter"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const current = parseInt(playerCount) || 0;
|
||||||
|
if (current > 0 && current < 100) {
|
||||||
|
setPlayerCount(String(current + 1));
|
||||||
|
} else if (!playerCount || current === 0) {
|
||||||
|
setPlayerCount('3');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-10 h-10 sm:w-8 sm:h-8 flex items-center justify-center bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg transition font-bold text-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled={playerCount && parseInt(playerCount) >= 100}
|
||||||
|
title="Increase"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-gray-700 font-semibold mb-2">
|
<label className="block text-sm text-gray-700 dark:text-gray-300 font-semibold mb-1">
|
||||||
Game Length
|
Game Length
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={lengthFilter}
|
value={lengthFilter}
|
||||||
onChange={(e) => setLengthFilter(e.target.value)}
|
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"
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm"
|
||||||
>
|
>
|
||||||
<option value="">Any</option>
|
<option value="">Any</option>
|
||||||
<option value="short">Short (≤15 min)</option>
|
<option value="short">Short <15 min</option>
|
||||||
<option value="medium">Medium (16-25 min)</option>
|
<option value="medium">Medium (16-25 min)</option>
|
||||||
<option value="long">Long (>25 min)</option>
|
<option value="long">Long >25 min</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Compact Toggle Filters */}
|
||||||
<label className="block text-gray-700 font-semibold mb-2">
|
<div className="grid grid-cols-2 gap-3 sm:gap-2">
|
||||||
Family Friendly
|
<div>
|
||||||
</label>
|
<label className="block text-xs text-gray-700 dark:text-gray-300 font-semibold mb-1">
|
||||||
<select
|
Drawing
|
||||||
value={familyFriendlyFilter}
|
</label>
|
||||||
onChange={(e) => setFamilyFriendlyFilter(e.target.value)}
|
<div className="flex border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
<button
|
||||||
>
|
onClick={() => setDrawingFilter('exclude')}
|
||||||
<option value="">Any</option>
|
className={`flex-1 py-2 sm:py-1.5 text-xs transition ${
|
||||||
<option value="yes">Yes</option>
|
drawingFilter === 'exclude'
|
||||||
<option value="no">No</option>
|
? 'bg-red-500 text-white'
|
||||||
</select>
|
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDrawingFilter('both')}
|
||||||
|
className={`flex-1 py-2 sm:py-1.5 text-xs border-x border-gray-300 dark:border-gray-600 transition ${
|
||||||
|
drawingFilter === 'both'
|
||||||
|
? 'bg-gray-500 text-white'
|
||||||
|
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Any
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDrawingFilter('only')}
|
||||||
|
className={`flex-1 py-2 sm:py-1.5 text-xs transition ${
|
||||||
|
drawingFilter === 'only'
|
||||||
|
? 'bg-green-500 text-white'
|
||||||
|
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-700 dark:text-gray-300 font-semibold mb-1">
|
||||||
|
Family Friendly
|
||||||
|
</label>
|
||||||
|
<div className="flex border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setFamilyFriendlyFilter('no')}
|
||||||
|
className={`flex-1 py-2 sm:py-1.5 text-xs transition ${
|
||||||
|
familyFriendlyFilter === 'no'
|
||||||
|
? 'bg-red-500 text-white'
|
||||||
|
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFamilyFriendlyFilter('')}
|
||||||
|
className={`flex-1 py-2 sm:py-1.5 text-xs border-x border-gray-300 dark:border-gray-600 transition ${
|
||||||
|
familyFriendlyFilter === ''
|
||||||
|
? 'bg-gray-500 text-white'
|
||||||
|
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Any
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFamilyFriendlyFilter('yes')}
|
||||||
|
className={`flex-1 py-2 sm:py-1.5 text-xs transition ${
|
||||||
|
familyFriendlyFilter === 'yes'
|
||||||
|
? 'bg-green-500 text-white'
|
||||||
|
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Game Pool Modal */}
|
||||||
|
{showGamePool && (
|
||||||
|
<GamePoolModal
|
||||||
|
games={eligibleGames}
|
||||||
|
onClose={() => setShowGamePool(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Results Panel */}
|
{/* Results Panel */}
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-100 border border-red-400 text-red-700 p-4 rounded mb-4">
|
<div className="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-200 p-4 rounded mb-4">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedGame && (
|
{selectedGame && (
|
||||||
<div className="bg-white rounded-lg shadow-lg p-8 mb-6">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-8 mb-6">
|
||||||
<h2 className="text-3xl font-bold mb-4 text-gray-800">
|
<h2 className="text-2xl sm:text-3xl font-bold mb-4 text-gray-800 dark:text-gray-100">
|
||||||
{selectedGame.title}
|
{selectedGame.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 mb-4">{selectedGame.pack_name}</p>
|
<p className="text-lg sm:text-xl text-gray-600 dark:text-gray-400 mb-4">{selectedGame.pack_name}</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
<div className="grid grid-cols-2 gap-3 sm:gap-4 mb-6 text-sm sm:text-base">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold text-gray-700">Players:</span>
|
<span className="font-semibold text-gray-700 dark:text-gray-300">Players:</span>
|
||||||
<span className="ml-2 text-gray-600">
|
<span className="ml-2 text-gray-600 dark:text-gray-400">
|
||||||
{selectedGame.min_players}-{selectedGame.max_players}
|
{selectedGame.min_players}-{selectedGame.max_players}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold text-gray-700">Length:</span>
|
<span className="font-semibold text-gray-700 dark:text-gray-300">Length:</span>
|
||||||
<span className="ml-2 text-gray-600">
|
<span className="ml-2 text-gray-600 dark:text-gray-400">
|
||||||
{selectedGame.length_minutes ? `${selectedGame.length_minutes} min` : 'Unknown'}
|
{selectedGame.length_minutes ? `${selectedGame.length_minutes} min` : 'Unknown'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold text-gray-700">Type:</span>
|
<span className="font-semibold text-gray-700 dark:text-gray-300">Type:</span>
|
||||||
<span className="ml-2 text-gray-600">
|
<span className="ml-2 text-gray-600 dark:text-gray-400">
|
||||||
{selectedGame.game_type || 'N/A'}
|
{selectedGame.game_type || 'N/A'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold text-gray-700">Family Friendly:</span>
|
<span className="font-semibold text-gray-700 dark:text-gray-300">Family Friendly:</span>
|
||||||
<span className="ml-2 text-gray-600">
|
<span className="ml-2 text-gray-600 dark:text-gray-400">
|
||||||
{selectedGame.family_friendly ? 'Yes' : 'No'}
|
{selectedGame.family_friendly ? 'Yes' : 'No'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold text-gray-700">Play Count:</span>
|
<span className="font-semibold text-gray-700 dark:text-gray-300">Play Count:</span>
|
||||||
<span className="ml-2 text-gray-600">{selectedGame.play_count}</span>
|
<span className="ml-2 text-gray-600 dark:text-gray-400">{selectedGame.play_count}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold text-gray-700">Popularity:</span>
|
<span className="font-semibold text-gray-700 dark:text-gray-300">Popularity:</span>
|
||||||
<span className="ml-2 text-gray-600">
|
<span className="ml-2 text-gray-600 dark:text-gray-400">
|
||||||
{selectedGame.popularity_score > 0 ? '+' : ''}
|
{selectedGame.popularity_score > 0 ? '+' : ''}
|
||||||
{selectedGame.popularity_score}
|
{selectedGame.popularity_score}
|
||||||
</span>
|
</span>
|
||||||
@@ -279,30 +554,65 @@ function Picker() {
|
|||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={handleAcceptGame}
|
onClick={handleAcceptGame}
|
||||||
className="flex-1 bg-green-600 text-white py-3 rounded-lg hover:bg-green-700 transition font-semibold"
|
className="flex-1 bg-green-600 dark:bg-green-700 text-white py-3 rounded-lg hover:bg-green-700 dark:hover:bg-green-800 transition font-semibold"
|
||||||
>
|
>
|
||||||
✓ Play This Game
|
✓ Play This Game
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handlePickGame}
|
onClick={handlePickGame}
|
||||||
className="flex-1 bg-yellow-600 text-white py-3 rounded-lg hover:bg-yellow-700 transition font-semibold"
|
className="flex-1 bg-yellow-600 dark:bg-yellow-700 text-white py-3 rounded-lg hover:bg-yellow-700 dark:hover:bg-yellow-800 transition font-semibold"
|
||||||
>
|
>
|
||||||
🎲 Re-roll
|
🎲 Re-roll
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Other Versions Suggestion */}
|
||||||
|
{similarVersions.length > 0 && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-lg font-semibold mb-3 text-gray-800 dark:text-gray-100">
|
||||||
|
🔄 Other Versions Available
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||||
|
This game has multiple versions. You can choose a different one:
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{similarVersions.map((version) => (
|
||||||
|
<button
|
||||||
|
key={version.id}
|
||||||
|
onClick={() => handleSelectVersion(version.id)}
|
||||||
|
className="w-full text-left p-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 transition group"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-semibold text-gray-800 dark:text-gray-100 group-hover:text-indigo-600 dark:group-hover:text-indigo-400">
|
||||||
|
{version.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{version.pack_name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 ml-2">
|
||||||
|
{version.min_players}-{version.max_players} players
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showManualSelect && (
|
{showManualSelect && (
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-6 mb-6">
|
||||||
<h3 className="text-xl font-semibold mb-4 text-gray-800">
|
<h3 className="text-lg sm:text-xl font-semibold mb-4 text-gray-800 dark:text-gray-100">
|
||||||
Manual Game Selection
|
Manual Game Selection
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex gap-4">
|
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
|
||||||
<select
|
<select
|
||||||
value={manualGameId}
|
value={manualGameId}
|
||||||
onChange={(e) => setManualGameId(e.target.value)}
|
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"
|
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
<option value="">Select a game...</option>
|
<option value="">Select a game...</option>
|
||||||
{allGames.map((game) => (
|
{allGames.map((game) => (
|
||||||
@@ -314,7 +624,7 @@ function Picker() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleAddManualGame}
|
onClick={handleAddManualGame}
|
||||||
disabled={!manualGameId}
|
disabled={!manualGameId}
|
||||||
className="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition disabled:bg-gray-400"
|
className="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition disabled:bg-gray-400 dark:disabled:bg-gray-600"
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
@@ -323,25 +633,28 @@ function Picker() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Session info and games */}
|
{/* Session info and games */}
|
||||||
<SessionInfo sessionId={activeSession.id} />
|
<SessionInfo sessionId={activeSession.id} onGamesUpdate={gamesUpdateTrigger} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionInfo({ sessionId }) {
|
function SessionInfo({ sessionId, onGamesUpdate }) {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
const [games, setGames] = useState([]);
|
const [games, setGames] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [confirmingRemove, setConfirmingRemove] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadGames();
|
loadGames();
|
||||||
}, [sessionId]);
|
}, [sessionId, onGamesUpdate]);
|
||||||
|
|
||||||
const loadGames = async () => {
|
const loadGames = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/sessions/${sessionId}/games`);
|
const response = await api.get(`/sessions/${sessionId}/games`);
|
||||||
setGames(response.data);
|
// Reverse chronological order (most recent first)
|
||||||
|
setGames(response.data.reverse());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load session games');
|
console.error('Failed to load session games');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -349,35 +662,145 @@ function SessionInfo({ sessionId }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUpdateStatus = async (gameId, newStatus) => {
|
||||||
|
try {
|
||||||
|
await api.patch(`/sessions/${sessionId}/games/${gameId}/status`, { status: newStatus });
|
||||||
|
loadGames(); // Reload to get updated statuses
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update game status', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveClick = (gameId) => {
|
||||||
|
if (confirmingRemove === gameId) {
|
||||||
|
// Second click - actually remove
|
||||||
|
handleRemoveGame(gameId);
|
||||||
|
} else {
|
||||||
|
// First click - show confirmation
|
||||||
|
setConfirmingRemove(gameId);
|
||||||
|
// Reset after 3 seconds
|
||||||
|
setTimeout(() => setConfirmingRemove(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveGame = async (gameId) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/sessions/${sessionId}/games/${gameId}`);
|
||||||
|
setConfirmingRemove(null);
|
||||||
|
loadGames(); // Reload after deletion
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to remove game', err);
|
||||||
|
setConfirmingRemove(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status) => {
|
||||||
|
if (status === 'playing') {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded font-semibold">
|
||||||
|
🎮 Playing
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === 'skipped') {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 px-2 py-1 rounded">
|
||||||
|
⏭️ Skipped
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-6">
|
||||||
<h3 className="text-xl font-semibold mb-4 text-gray-800">
|
<h3 className="text-lg sm:text-xl font-semibold mb-4 text-gray-800 dark:text-gray-100">
|
||||||
Games Played This Session ({games.length})
|
Games Played This Session ({games.length})
|
||||||
</h3>
|
</h3>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-gray-500">Loading...</p>
|
<p className="text-gray-500 dark:text-gray-400">Loading...</p>
|
||||||
) : games.length === 0 ? (
|
) : games.length === 0 ? (
|
||||||
<p className="text-gray-500">No games played yet. Pick a game to get started!</p>
|
<p className="text-gray-500 dark:text-gray-400">No games played yet. Pick a game to get started!</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
{games.map((game, index) => (
|
{games.map((game) => {
|
||||||
<div key={game.id} className="flex items-center justify-between p-3 bg-gray-50 rounded">
|
const index = games.length - games.indexOf(game);
|
||||||
<div>
|
return (
|
||||||
<span className="font-semibold text-gray-700">
|
<div
|
||||||
{index + 1}. {game.title}
|
key={game.id}
|
||||||
</span>
|
className={`p-3 rounded border transition ${
|
||||||
<span className="text-gray-500 ml-2 text-sm">({game.pack_name})</span>
|
game.status === 'playing'
|
||||||
{game.manually_added === 1 && (
|
? 'bg-green-50 dark:bg-green-900/20 border-green-300 dark:border-green-700'
|
||||||
<span className="ml-2 text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
|
: game.status === 'skipped'
|
||||||
Manual
|
? 'bg-gray-100 dark:bg-gray-700/50 border-gray-300 dark:border-gray-600'
|
||||||
</span>
|
: 'bg-gray-50 dark:bg-gray-700/30 border-gray-200 dark:border-gray-600'
|
||||||
)}
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className={`font-semibold text-sm sm:text-base ${
|
||||||
|
game.status === 'skipped'
|
||||||
|
? 'text-gray-500 dark:text-gray-500 line-through'
|
||||||
|
: 'text-gray-700 dark:text-gray-200'
|
||||||
|
}`}>
|
||||||
|
{index + 1}. {game.title}
|
||||||
|
</span>
|
||||||
|
{getStatusBadge(game.status)}
|
||||||
|
{game.manually_added === 1 && (
|
||||||
|
<span className="text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded">
|
||||||
|
Manual
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs sm:text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{game.pack_name} • {formatLocalTime(game.played_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-gray-500">
|
|
||||||
{new Date(game.played_at).toLocaleTimeString()}
|
{/* Action buttons for admins */}
|
||||||
</span>
|
{isAuthenticated && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{game.status !== 'playing' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleUpdateStatus(game.id, 'playing')}
|
||||||
|
className="text-xs px-3 py-1 bg-green-600 dark:bg-green-700 text-white rounded hover:bg-green-700 dark:hover:bg-green-800 transition"
|
||||||
|
>
|
||||||
|
Mark as Playing
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{game.status === 'playing' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleUpdateStatus(game.id, 'played')}
|
||||||
|
className="text-xs px-3 py-1 bg-blue-600 dark:bg-blue-700 text-white rounded hover:bg-blue-700 dark:hover:bg-blue-800 transition"
|
||||||
|
>
|
||||||
|
Mark as Played
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{game.status !== 'skipped' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleUpdateStatus(game.id, 'skipped')}
|
||||||
|
className="text-xs px-3 py-1 bg-gray-600 dark:bg-gray-700 text-white rounded hover:bg-gray-700 dark:hover:bg-gray-800 transition"
|
||||||
|
>
|
||||||
|
Mark as Skipped
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveClick(game.id)}
|
||||||
|
className={`text-xs px-3 py-1 rounded transition ${
|
||||||
|
confirmingRemove === game.id
|
||||||
|
? 'bg-red-700 dark:bg-red-800 text-white animate-pulse'
|
||||||
|
: 'bg-red-600 dark:bg-red-700 text-white hover:bg-red-700 dark:hover:bg-red-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmingRemove === game.id ? 'Confirm?' : 'Remove'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
49
frontend/src/utils/dateUtils.js
Normal file
49
frontend/src/utils/dateUtils.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Parse a SQLite timestamp (which is stored as UTC) into a JavaScript Date object.
|
||||||
|
* SQLite CURRENT_TIMESTAMP returns: "YYYY-MM-DD HH:MM:SS" in UTC
|
||||||
|
* But without a 'Z' suffix, JavaScript treats it as local time.
|
||||||
|
* This function ensures it's interpreted as UTC.
|
||||||
|
*
|
||||||
|
* @param {string} sqliteTimestamp - Timestamp from SQLite (e.g., "2025-10-30 14:30:00")
|
||||||
|
* @returns {Date} - JavaScript Date object
|
||||||
|
*/
|
||||||
|
export function parseUTCTimestamp(sqliteTimestamp) {
|
||||||
|
if (!sqliteTimestamp) return new Date();
|
||||||
|
|
||||||
|
// If timestamp already has 'Z' or timezone info, use it directly
|
||||||
|
if (sqliteTimestamp.includes('Z') || sqliteTimestamp.includes('+') || sqliteTimestamp.includes('T')) {
|
||||||
|
return new Date(sqliteTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQLite format: "YYYY-MM-DD HH:MM:SS"
|
||||||
|
// Add 'Z' to explicitly mark it as UTC
|
||||||
|
return new Date(sqliteTimestamp + 'Z');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a SQLite timestamp as a localized date string
|
||||||
|
* @param {string} sqliteTimestamp
|
||||||
|
* @returns {string} - Localized date (e.g., "10/30/2025")
|
||||||
|
*/
|
||||||
|
export function formatLocalDate(sqliteTimestamp) {
|
||||||
|
return parseUTCTimestamp(sqliteTimestamp).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a SQLite timestamp as a localized time string
|
||||||
|
* @param {string} sqliteTimestamp
|
||||||
|
* @returns {string} - Localized time (e.g., "2:30:00 PM")
|
||||||
|
*/
|
||||||
|
export function formatLocalTime(sqliteTimestamp) {
|
||||||
|
return parseUTCTimestamp(sqliteTimestamp).toLocaleTimeString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a SQLite timestamp as a localized date and time string
|
||||||
|
* @param {string} sqliteTimestamp
|
||||||
|
* @returns {string} - Localized date and time (e.g., "10/30/2025, 2:30:00 PM")
|
||||||
|
*/
|
||||||
|
export function formatLocalDateTime(sqliteTimestamp) {
|
||||||
|
return parseUTCTimestamp(sqliteTimestamp).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,11 +1,36 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
module.exports = {
|
||||||
|
darkMode: 'class',
|
||||||
content: [
|
content: [
|
||||||
"./index.html",
|
"./index.html",
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: '#eef2ff',
|
||||||
|
100: '#e0e7ff',
|
||||||
|
200: '#c7d2fe',
|
||||||
|
300: '#a5b4fc',
|
||||||
|
400: '#818cf8',
|
||||||
|
500: '#6366f1',
|
||||||
|
600: '#4f46e5',
|
||||||
|
700: '#4338ca',
|
||||||
|
800: '#3730a3',
|
||||||
|
900: '#312e81',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
'slide-in': {
|
||||||
|
'0%': { transform: 'translateX(100%)', opacity: '0' },
|
||||||
|
'100%': { transform: 'translateX(0)', opacity: '1' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'slide-in': 'slide-in 0.3s ease-out'
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineConfig } from 'vite';
|
const { defineConfig } = require('vite');
|
||||||
import react from '@vitejs/plugin-react';
|
const react = require('@vitejs/plugin-react');
|
||||||
|
|
||||||
export default defineConfig({
|
module.exports = defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
|
|||||||
34
todos.md
Normal file
34
todos.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# TODO:
|
||||||
|
|
||||||
|
## Chrome Extension
|
||||||
|
- [ ] /.old-chrome-extension/ contains OLD code that needs adjusting for new game picker format.
|
||||||
|
- [ ] remove clunky gamealias system, we are only tracking "thisgame++" and "thisgame--" now.
|
||||||
|
- [ ] ensure the extension is watching for "thisgame++" or "thisgame--" anywhere in each chat line.
|
||||||
|
- [ ] if a chat line matches capture the whole message/line, the author, and the timestamp (UTC).
|
||||||
|
- [ ] ensure our JSON output matches the new 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Game Manager
|
||||||
|
- [x] implement favoring system (for packs and games). (COMPLETED: Full weighted selection system with visual indicators)
|
||||||
|
- [x] if a game or pack is marked favored (👍), then we bias the picking algorithm to pick those games.
|
||||||
|
- [x] if a game or pack is marked as disfavored (👎), then we bias the picking algorithm to not pick those games.
|
||||||
|
- [x] biased games/packs should be highlighted subtley somehow in *any* lists they're in elsewhere in the UI, like the Pool Viewer.
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
- [x] Entire App: local timezone display still isn't working. I see UTC times. (FIXED: Created dateUtils.js to properly parse SQLite UTC timestamps)
|
||||||
|
|
||||||
|
## Other Features
|
||||||
|
- [ ] Session History: export sessions to plaintext and JSON.
|
||||||
Reference in New Issue
Block a user