Compare commits
11 Commits
195448644a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c24223be5c
|
||
|
|
a2164c8242
|
||
|
|
1d7395e121
|
||
|
|
26ce643351
|
||
|
|
59db8f6ed7
|
||
|
|
a1078e0cc7
|
||
|
|
4bbc1856f5
|
||
|
|
4be520476c
|
||
|
|
10c34557c5
|
||
|
|
9cd601bab2
|
||
|
|
1c9f0ef280
|
@@ -8,6 +8,7 @@
|
||||
"FBG4": "Fibbage 4",
|
||||
"TMP1": "Trivia Murder Party",
|
||||
"TMP2": "Trivia Murder Party 2",
|
||||
"TMP3": "Trivia Murder Party 3 (Playtest)",
|
||||
"DRWF": "Drawful",
|
||||
"DRWA": "Drawful Animate",
|
||||
"DD": "Dirty Drawful",
|
||||
|
||||
@@ -63,6 +63,50 @@ function initializeDatabase() {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
// Poll state columns on sessions
|
||||
try {
|
||||
db.exec(`ALTER TABLE sessions ADD COLUMN poll_active INTEGER DEFAULT 0`);
|
||||
} catch (err) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
try {
|
||||
db.exec(`ALTER TABLE sessions ADD COLUMN poll_started_at TEXT`);
|
||||
} catch (err) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
try {
|
||||
db.exec(`ALTER TABLE sessions ADD COLUMN poll_leading_game_id INTEGER`);
|
||||
} catch (err) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
try {
|
||||
db.exec(`ALTER TABLE sessions ADD COLUMN poll_leading_label TEXT`);
|
||||
} catch (err) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
try {
|
||||
db.exec(`ALTER TABLE sessions ADD COLUMN poll_leading_votes INTEGER`);
|
||||
} catch (err) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
try {
|
||||
db.exec(`ALTER TABLE sessions ADD COLUMN poll_ending_at TEXT`);
|
||||
} catch (err) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
// Pending game selection columns on sessions
|
||||
try {
|
||||
db.exec(`ALTER TABLE sessions ADD COLUMN pending_game_id INTEGER`);
|
||||
} catch (err) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
try {
|
||||
db.exec(`ALTER TABLE sessions ADD COLUMN pending_game_source TEXT`);
|
||||
} catch (err) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
// Session games table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS session_games (
|
||||
@@ -105,6 +149,13 @@ function initializeDatabase() {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
// Add source column: 'dice' (random pick), 'manual' (admin search), 'poll' (poll winner)
|
||||
try {
|
||||
db.exec(`ALTER TABLE session_games ADD COLUMN source TEXT DEFAULT 'dice'`);
|
||||
} 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`);
|
||||
|
||||
@@ -10,6 +10,9 @@ const { optionalAuthenticateToken } = require('../middleware/optional-auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Active poll-ending timers keyed by sessionId
|
||||
const pollEndTimers = new Map();
|
||||
|
||||
// Helper function to create a hash of a message
|
||||
function createMessageHash(username, message, timestamp) {
|
||||
return crypto
|
||||
@@ -327,6 +330,222 @@ router.post('/:id/close', authenticateToken, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Start voting/polling for a session (broadcasts poll.start to subscribers)
|
||||
router.post('/:id/voting/start', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const sessionId = parseInt(req.params.id);
|
||||
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
if (session.is_active === 0) {
|
||||
return res.status(400).json({ error: 'Session is not active' });
|
||||
}
|
||||
|
||||
// Cancel any pending end-poll timer from a previous poll
|
||||
const existingTimer = pollEndTimers.get(sessionId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
pollEndTimers.delete(sessionId);
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
'UPDATE sessions SET poll_active = 1, poll_started_at = ?, poll_ending_at = NULL, poll_leading_game_id = NULL, poll_leading_label = NULL, poll_leading_votes = NULL WHERE id = ?'
|
||||
).run(new Date().toISOString(), sessionId);
|
||||
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
wsManager.broadcastEvent('poll.start', {
|
||||
sessionId,
|
||||
pollStartedAt: new Date().toISOString()
|
||||
}, sessionId);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Immediately end a poll for a session (internal helper, also used by the timer callback)
|
||||
function endPollNow(sessionId) {
|
||||
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId);
|
||||
if (!session || session.poll_active === 0) return;
|
||||
|
||||
db.prepare(
|
||||
'UPDATE sessions SET poll_active = 0, poll_started_at = NULL, poll_ending_at = NULL WHERE id = ?'
|
||||
).run(sessionId);
|
||||
|
||||
pollEndTimers.delete(sessionId);
|
||||
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
wsManager.broadcastEvent('voting.ended', {
|
||||
sessionId,
|
||||
winnerGameId: session.poll_leading_game_id,
|
||||
winnerLabel: session.poll_leading_label,
|
||||
winnerVotes: session.poll_leading_votes
|
||||
}, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// End voting/polling for a session (broadcasts voting.ended to subscribers)
|
||||
// Accepts optional { delay } in body (seconds, 0-300). 0 or omitted = immediate.
|
||||
router.post('/:id/voting/end', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const sessionId = parseInt(req.params.id);
|
||||
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
if (session.is_active === 0) {
|
||||
return res.status(400).json({ error: 'Session is not active' });
|
||||
}
|
||||
|
||||
const delay = Math.max(0, Math.min(300, parseInt(req.body.delay) || 0));
|
||||
|
||||
// Clear any existing timer first
|
||||
const existingTimer = pollEndTimers.get(sessionId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
pollEndTimers.delete(sessionId);
|
||||
}
|
||||
|
||||
if (delay === 0) {
|
||||
endPollNow(sessionId);
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
// Schedule a delayed end
|
||||
const endsAt = new Date(Date.now() + delay * 1000).toISOString();
|
||||
|
||||
db.prepare(
|
||||
'UPDATE sessions SET poll_ending_at = ? WHERE id = ?'
|
||||
).run(endsAt, sessionId);
|
||||
|
||||
const timerId = setTimeout(() => endPollNow(sessionId), delay * 1000);
|
||||
pollEndTimers.set(sessionId, timerId);
|
||||
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
wsManager.broadcastEvent('poll.ending', {
|
||||
sessionId,
|
||||
endsAt,
|
||||
delaySeconds: delay
|
||||
}, sessionId);
|
||||
}
|
||||
|
||||
res.json({ success: true, endsAt, delaySeconds: delay });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel a scheduled poll end (broadcasts poll.ending.cancelled to subscribers)
|
||||
router.post('/:id/voting/cancel-end', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const sessionId = parseInt(req.params.id);
|
||||
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
const timerId = pollEndTimers.get(sessionId);
|
||||
if (timerId) {
|
||||
clearTimeout(timerId);
|
||||
pollEndTimers.delete(sessionId);
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
'UPDATE sessions SET poll_ending_at = NULL WHERE id = ?'
|
||||
).run(sessionId);
|
||||
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
wsManager.broadcastEvent('poll.ending.cancelled', { sessionId }, sessionId);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Set the pending game selection for a session (broadcasts game.picked to subscribers)
|
||||
router.post('/:id/game-selection', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const sessionId = parseInt(req.params.id);
|
||||
const { game_id, source } = req.body;
|
||||
|
||||
if (!game_id) {
|
||||
return res.status(400).json({ error: 'game_id is required' });
|
||||
}
|
||||
|
||||
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId);
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
if (session.is_active === 0) {
|
||||
return res.status(400).json({ error: 'Session is not active' });
|
||||
}
|
||||
|
||||
const game = db.prepare('SELECT * FROM games WHERE id = ?').get(game_id);
|
||||
if (!game) {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
|
||||
const validSources = ['dice', 'poll', 'manual'];
|
||||
const gameSource = validSources.includes(source) ? source : 'dice';
|
||||
|
||||
db.prepare(
|
||||
'UPDATE sessions SET pending_game_id = ?, pending_game_source = ? WHERE id = ?'
|
||||
).run(game_id, gameSource, sessionId);
|
||||
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
wsManager.broadcastEvent('game.picked', {
|
||||
sessionId,
|
||||
game,
|
||||
source: gameSource
|
||||
}, sessionId);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Clear the pending game selection for a session (broadcasts game.dismissed to subscribers)
|
||||
router.delete('/:id/game-selection', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const sessionId = parseInt(req.params.id);
|
||||
|
||||
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId);
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
'UPDATE sessions SET pending_game_id = NULL, pending_game_source = NULL WHERE id = ?'
|
||||
).run(sessionId);
|
||||
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
wsManager.broadcastEvent('game.dismissed', { sessionId }, sessionId);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete session (admin only)
|
||||
router.delete('/:id', authenticateToken, (req, res) => {
|
||||
try {
|
||||
@@ -495,7 +714,9 @@ router.get('/:id/votes', (req, res) => {
|
||||
// Add game to session (admin only)
|
||||
router.post('/:id/games', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const { game_id, manually_added, room_code } = req.body;
|
||||
const { game_id, manually_added, room_code, source } = req.body;
|
||||
const validSources = ['dice', 'manual', 'poll'];
|
||||
const gameSource = validSources.includes(source) ? source : (manually_added ? 'manual' : 'dice');
|
||||
|
||||
if (!game_id) {
|
||||
return res.status(400).json({ error: 'game_id is required' });
|
||||
@@ -539,11 +760,16 @@ router.post('/:id/games', authenticateToken, (req, res) => {
|
||||
|
||||
// Add game to session with 'playing' status
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO session_games (session_id, game_id, manually_added, status, room_code)
|
||||
VALUES (?, ?, ?, 'playing', ?)
|
||||
INSERT INTO session_games (session_id, game_id, manually_added, status, room_code, source)
|
||||
VALUES (?, ?, ?, 'playing', ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(req.params.id, game_id, manually_added ? 1 : 0, room_code || null);
|
||||
const result = stmt.run(req.params.id, game_id, manually_added ? 1 : 0, room_code || null, gameSource);
|
||||
|
||||
// Clear pending game selection since the game is now formally added
|
||||
db.prepare(
|
||||
'UPDATE sessions SET pending_game_id = NULL, pending_game_source = NULL WHERE id = ?'
|
||||
).run(req.params.id);
|
||||
|
||||
// Increment play count for the game
|
||||
db.prepare('UPDATE games SET play_count = play_count + 1 WHERE id = ?').run(game_id);
|
||||
@@ -586,7 +812,8 @@ router.post('/:id/games', authenticateToken, (req, res) => {
|
||||
min_players: game.min_players,
|
||||
max_players: game.max_players,
|
||||
manually_added: manually_added || false,
|
||||
room_code: room_code || null
|
||||
room_code: room_code || null,
|
||||
source: gameSource
|
||||
}
|
||||
};
|
||||
|
||||
@@ -988,7 +1215,8 @@ router.get('/:id/export', authenticateToken, (req, res) => {
|
||||
type: game.game_type,
|
||||
played_at: game.played_at,
|
||||
manually_added: game.manually_added === 1,
|
||||
status: game.status
|
||||
status: game.status,
|
||||
source: game.source || 'dice'
|
||||
})),
|
||||
chat_logs: chatLogs.map(log => ({
|
||||
username: log.chatter_name,
|
||||
@@ -1029,7 +1257,9 @@ router.get('/:id/export', authenticateToken, (req, res) => {
|
||||
}
|
||||
text += ` Played: ${game.played_at}\n`;
|
||||
text += ` Status: ${game.status}\n`;
|
||||
if (game.manually_added === 1) {
|
||||
if (game.source && game.source !== 'dice') {
|
||||
text += ` Source: ${game.source}\n`;
|
||||
} else if (game.manually_added === 1) {
|
||||
text += ` (Manually Added)\n`;
|
||||
}
|
||||
});
|
||||
@@ -1205,5 +1435,25 @@ router.patch('/:sessionId/games/:gameId/player-count', authenticateToken, (req,
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
// Reschedule poll-end timers for sessions that had a pending countdown when the server stopped.
|
||||
// Call once after the HTTP + WS servers are ready.
|
||||
function rescheduleEndingPolls() {
|
||||
const rows = db.prepare(
|
||||
'SELECT id, poll_ending_at FROM sessions WHERE poll_active = 1 AND poll_ending_at IS NOT NULL'
|
||||
).all();
|
||||
|
||||
for (const row of rows) {
|
||||
const remaining = new Date(row.poll_ending_at).getTime() - Date.now();
|
||||
if (remaining <= 0) {
|
||||
endPollNow(row.id);
|
||||
} else {
|
||||
const timerId = setTimeout(() => endPollNow(row.id), remaining);
|
||||
pollEndTimers.set(row.id, timerId);
|
||||
console.log(`[Sessions] Rescheduled poll end for session ${row.id} in ${Math.round(remaining / 1000)}s`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
module.exports.rescheduleEndingPolls = rescheduleEndingPolls;
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ app.get('/health', (req, res) => {
|
||||
const authRoutes = require('./routes/auth');
|
||||
const gamesRoutes = require('./routes/games');
|
||||
const sessionsRoutes = require('./routes/sessions');
|
||||
const { rescheduleEndingPolls } = require('./routes/sessions');
|
||||
const statsRoutes = require('./routes/stats');
|
||||
const pickerRoutes = require('./routes/picker');
|
||||
const votesRoutes = require('./routes/votes');
|
||||
@@ -54,6 +55,7 @@ if (require.main === module) {
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Server is running on port ${PORT}`);
|
||||
console.log(`WebSocket server available at ws://localhost:${PORT}/api/sessions/live`);
|
||||
rescheduleEndingPolls();
|
||||
});
|
||||
|
||||
const shutdown = async () => {
|
||||
|
||||
@@ -172,8 +172,9 @@ class EcastShardClient {
|
||||
this.playerCount = playerCount;
|
||||
this.playerNames = playerNames;
|
||||
}
|
||||
} else if (msg.opcode === 'error' && msg.result?.code === 2027) {
|
||||
this.gameFinished = true;
|
||||
} else if (msg.opcode === 'error') {
|
||||
// Probe errors are transient — lifecycle is managed
|
||||
// by the main connection and reconnect logic
|
||||
}
|
||||
} catch (_) {}
|
||||
clearTimeout(timeout);
|
||||
@@ -562,6 +563,8 @@ class EcastShardClient {
|
||||
finalPlayerCount: this.playerCount,
|
||||
});
|
||||
activeShards.delete(`${this.sessionId}-${this.gameId}`);
|
||||
this.gameFinished = true;
|
||||
this.disconnect();
|
||||
return false;
|
||||
} finally {
|
||||
this.reconnecting = false;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { WebSocketServer } = require('ws');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { JWT_SECRET } = require('../middleware/auth');
|
||||
const db = require('../database');
|
||||
|
||||
/**
|
||||
* WebSocket Manager for handling real-time session events
|
||||
@@ -111,6 +112,35 @@ class WebSocketManager {
|
||||
clientInfo.currentPage = message.page || null;
|
||||
this.broadcastPresence();
|
||||
break;
|
||||
|
||||
case 'poll.leading':
|
||||
if (!clientInfo.authenticated) {
|
||||
this.sendError(ws, 'Not authenticated');
|
||||
return;
|
||||
}
|
||||
if (message.sessionId) {
|
||||
this.broadcastEvent('poll.leading', {
|
||||
sessionId: message.sessionId,
|
||||
gameId: message.gameId,
|
||||
label: message.label,
|
||||
votes: message.votes
|
||||
}, message.sessionId);
|
||||
try {
|
||||
const result = db.prepare(
|
||||
'UPDATE sessions SET poll_leading_game_id = ?, poll_leading_label = ?, poll_leading_votes = ? WHERE id = ? AND poll_active = 1'
|
||||
).run(message.gameId, message.label, message.votes, message.sessionId);
|
||||
if (result.changes === 0) {
|
||||
// Self-heal: poll.leading arrived but poll_active is 0 (poll started before persistence fix)
|
||||
db.prepare(
|
||||
'UPDATE sessions SET poll_active = 1, poll_started_at = ?, poll_leading_game_id = ?, poll_leading_label = ?, poll_leading_votes = ? WHERE id = ? AND is_active = 1'
|
||||
).run(new Date().toISOString(), message.gameId, message.label, message.votes, message.sessionId);
|
||||
console.log(`[WebSocket] Self-healed poll state for session ${message.sessionId}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WebSocket] Failed to persist poll.leading:', err.message);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
this.sendError(ws, `Unknown message type: ${message.type}`);
|
||||
|
||||
@@ -17,6 +17,7 @@ services:
|
||||
- jackbox-data:/app/data
|
||||
- ./games-list.csv:/app/games-list.csv:ro,z
|
||||
- ./backend/config/admins.json:/app/config/admins.json:ro,z
|
||||
- ./backend/config/tickers.json:/app/config/tickers.json:ro,z
|
||||
ports:
|
||||
- "5000:5000"
|
||||
networks:
|
||||
|
||||
@@ -179,4 +179,4 @@ Most list endpoints return full result sets. The exception is `GET /api/votes`,
|
||||
- [OpenAPI Spec](openapi.yaml)
|
||||
- **Endpoint docs**: [Auth](endpoints/auth.md), [Games](endpoints/games.md), [Sessions](endpoints/sessions.md), [Picker](endpoints/picker.md), [Stats](endpoints/stats.md), [Votes](endpoints/votes.md), [Webhooks](endpoints/webhooks.md)
|
||||
- [WebSocket Protocol](websocket.md)
|
||||
- **Guides**: [Getting Started](guides/getting-started.md), [Session Lifecycle](guides/session-lifecycle.md), [Voting & Popularity](guides/voting-and-popularity.md), [Webhooks & Events](guides/webhooks-and-events.md)
|
||||
- **Guides**: [Getting Started](guides/getting-started.md), [Session Lifecycle](guides/session-lifecycle.md), [Game Status by Session](guides/game-status-by-session.md), [Voting & Popularity](guides/voting-and-popularity.md), [Webhooks & Events](guides/webhooks-and-events.md)
|
||||
|
||||
187
docs/api/guides/game-status-by-session.md
Normal file
187
docs/api/guides/game-status-by-session.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Game Status by Session
|
||||
|
||||
How to build a complete view of all enabled games—with popularity data and whether each game has been played in the current session. No single endpoint returns all three pieces today; this guide shows how to combine two public endpoints and join the results client-side.
|
||||
|
||||
---
|
||||
|
||||
## 1. Fetch Enabled Games
|
||||
|
||||
`GET /api/games?enabled=true` returns every enabled game in the catalog. Each row includes `popularity_score`, `upvotes`, `downvotes`, `play_count`, and `favor_bias`.
|
||||
|
||||
**Why:** This gives you the full list of games that are available for play, along with their cumulative popularity data across all sessions.
|
||||
|
||||
See [Games endpoints](../endpoints/games.md) for all available filters (`playerCount`, `drawing`, `length`, `familyFriendly`, `pack`).
|
||||
|
||||
```bash
|
||||
curl "http://localhost:5000/api/games?enabled=true"
|
||||
```
|
||||
|
||||
**Sample response (200 OK):**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"pack_name": "Jackbox Party Pack 7",
|
||||
"title": "Quiplash 3",
|
||||
"min_players": 3,
|
||||
"max_players": 8,
|
||||
"length_minutes": 15,
|
||||
"has_audience": 1,
|
||||
"family_friendly": 0,
|
||||
"game_type": "Writing",
|
||||
"secondary_type": null,
|
||||
"play_count": 4,
|
||||
"popularity_score": 7,
|
||||
"upvotes": 10,
|
||||
"downvotes": 3,
|
||||
"enabled": 1,
|
||||
"favor_bias": 0,
|
||||
"created_at": "2024-01-15T12:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"pack_name": "Jackbox Party Pack 9",
|
||||
"title": "Fibbage 4",
|
||||
"min_players": 2,
|
||||
"max_players": 8,
|
||||
"length_minutes": 15,
|
||||
"has_audience": 1,
|
||||
"family_friendly": 1,
|
||||
"game_type": "Trivia",
|
||||
"secondary_type": null,
|
||||
"play_count": 2,
|
||||
"popularity_score": 3,
|
||||
"upvotes": 5,
|
||||
"downvotes": 2,
|
||||
"enabled": 1,
|
||||
"favor_bias": 1,
|
||||
"created_at": "2024-01-15T12:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Fetch Session Games
|
||||
|
||||
`GET /api/sessions/{id}/games` returns games that have been added to a specific session. Each row includes `status` (`playing`, `played`, or `skipped`), `played_at`, and joined popularity fields from the game catalog.
|
||||
|
||||
**Why:** This tells you which games have already been played (or are currently playing) in the session, so you can mark them in the catalog.
|
||||
|
||||
See [Sessions endpoints](../endpoints/sessions.md) for full details on session game fields.
|
||||
|
||||
```bash
|
||||
curl "http://localhost:5000/api/sessions/5/games"
|
||||
```
|
||||
|
||||
**Sample response (200 OK):**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 14,
|
||||
"session_id": 5,
|
||||
"game_id": 1,
|
||||
"played_at": "2026-03-15T20:30:00.000Z",
|
||||
"manually_added": 0,
|
||||
"status": "played",
|
||||
"room_code": "LSBN",
|
||||
"player_count": 6,
|
||||
"player_count_check_status": "completed",
|
||||
"pack_name": "Jackbox Party Pack 7",
|
||||
"title": "Quiplash 3",
|
||||
"game_type": "Writing",
|
||||
"min_players": 3,
|
||||
"max_players": 8,
|
||||
"popularity_score": 7,
|
||||
"upvotes": 10,
|
||||
"downvotes": 3
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Key fields for the join:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `game_id` | Matches `id` in the games catalog |
|
||||
| `status` | `playing`, `played`, or `skipped` |
|
||||
| `played_at` | When the game was added to the session |
|
||||
|
||||
---
|
||||
|
||||
## 3. Combine Client-Side
|
||||
|
||||
Join the two responses by matching `game_id` (from session games) to `id` (from the catalog). This produces a single list of enabled games annotated with their session play status.
|
||||
|
||||
```javascript
|
||||
const gamesRes = await fetch('/api/games?enabled=true');
|
||||
const games = await gamesRes.json();
|
||||
|
||||
const sessionGamesRes = await fetch('/api/sessions/5/games');
|
||||
const sessionGames = await sessionGamesRes.json();
|
||||
|
||||
const sessionGameMap = new Map(
|
||||
sessionGames.map(sg => [sg.game_id, sg])
|
||||
);
|
||||
|
||||
const combined = games.map(game => {
|
||||
const sessionEntry = sessionGameMap.get(game.id);
|
||||
return {
|
||||
...game,
|
||||
playedInSession: !!sessionEntry,
|
||||
sessionStatus: sessionEntry?.status ?? null,
|
||||
sessionPlayedAt: sessionEntry?.played_at ?? null,
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
**Sample merged output (one entry):**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"pack_name": "Jackbox Party Pack 7",
|
||||
"title": "Quiplash 3",
|
||||
"min_players": 3,
|
||||
"max_players": 8,
|
||||
"length_minutes": 15,
|
||||
"has_audience": 1,
|
||||
"family_friendly": 0,
|
||||
"game_type": "Writing",
|
||||
"play_count": 4,
|
||||
"popularity_score": 7,
|
||||
"upvotes": 10,
|
||||
"downvotes": 3,
|
||||
"enabled": 1,
|
||||
"favor_bias": 0,
|
||||
"playedInSession": true,
|
||||
"sessionStatus": "played",
|
||||
"sessionPlayedAt": "2026-03-15T20:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
Games that have not been played in the session will have `playedInSession: false`, `sessionStatus: null`, and `sessionPlayedAt: null`.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Data point | `GET /api/games` | `GET /api/sessions/{id}/games` |
|
||||
|------------|------------------|-------------------------------|
|
||||
| Enabled status | `enabled` field + `?enabled=true` filter | Not included |
|
||||
| Popularity score | `popularity_score`, `upvotes`, `downvotes` | `popularity_score`, `upvotes`, `downvotes` (joined) |
|
||||
| All-time play count | `play_count` | Not included |
|
||||
| Played in session | Not included | `status` field (`playing`/`played`/`skipped`) |
|
||||
| Auth required | No | No |
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Games endpoints](../endpoints/games.md) — full catalog CRUD, filters, favor bias
|
||||
- [Sessions endpoints](../endpoints/sessions.md) — session games, status updates, room codes
|
||||
- [Picker endpoint](../endpoints/picker.md) — weighted random selection with session exclusion
|
||||
- [Stats endpoint](../endpoints/stats.md) — `mostPlayedGames`, `topRatedGames`
|
||||
- [Voting & Popularity guide](voting-and-popularity.md) — how votes and popularity scores work
|
||||
@@ -90,6 +90,7 @@ Obtain a JWT by calling `POST /api/auth/login` with your admin key.
|
||||
| `subscribe` | `sessionId` | Subscribe to a session's events |
|
||||
| `unsubscribe`| `sessionId` | Unsubscribe from a session |
|
||||
| `ping` | — | Heartbeat; server responds with `pong` |
|
||||
| `poll.leading` | `sessionId`, `gameId`, `label`, `votes` | Report current poll leader (rebroadcast to subscribers) |
|
||||
|
||||
### auth
|
||||
```json
|
||||
@@ -139,6 +140,11 @@ Must be authenticated.
|
||||
| `game.status` | Periodic game state heartbeat every 20s (broadcast to subscribers) |
|
||||
| `player-count.updated` | Manual player count override (broadcast to subscribers) |
|
||||
| `vote.received` | Live vote recorded (broadcast to subscribers) |
|
||||
| `voting.ended` | Host ended the voting/polling period (broadcast to subscribers) |
|
||||
| `poll.start` | Host started a new poll (broadcast to subscribers) |
|
||||
| `poll.leading` | Current poll leader updated (broadcast to subscribers) |
|
||||
| `poll.ending` | Poll is ending after a countdown (broadcast to subscribers) |
|
||||
| `poll.ending.cancelled`| Scheduled poll end was cancelled (broadcast to subscribers) |
|
||||
|
||||
---
|
||||
|
||||
@@ -384,6 +390,84 @@ Possible `reason` values: `room_closed`, `room_not_found`, `connection_failed`,
|
||||
}
|
||||
```
|
||||
|
||||
### voting.ended
|
||||
|
||||
- **Broadcast to:** Clients subscribed to the session
|
||||
- **Triggered by:** `POST /api/sessions/:id/voting/end` (host ends the voting/polling period, either immediately or when a countdown reaches zero)
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"sessionId": 3,
|
||||
"winnerGameId": 42,
|
||||
"winnerLabel": "Quiplash 3",
|
||||
"winnerVotes": 7
|
||||
}
|
||||
```
|
||||
|
||||
### poll.start
|
||||
|
||||
- **Broadcast to:** Clients subscribed to the session
|
||||
- **Triggered by:** `POST /api/sessions/:id/voting/start` (host starts a new poll)
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"sessionId": 3
|
||||
}
|
||||
```
|
||||
|
||||
### poll.leading
|
||||
|
||||
- **Broadcast to:** Clients subscribed to the session
|
||||
- **Triggered by:** Downstream voting client sends `poll.leading` message (rebroadcast to all session subscribers)
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"sessionId": 3,
|
||||
"gameId": 42,
|
||||
"label": "Quiplash 3",
|
||||
"votes": 7
|
||||
}
|
||||
```
|
||||
|
||||
### poll.ending
|
||||
|
||||
- **Broadcast to:** Clients subscribed to the session
|
||||
- **Triggered by:** `POST /api/sessions/:id/voting/end` with `{ "delay": N }` where N > 0
|
||||
|
||||
Signals that a countdown has started and the poll will automatically end when the timer reaches zero. All connected admin clients should display the countdown. The `endsAt` timestamp is authoritative; derive the remaining time on each client by comparing against the local clock.
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"sessionId": 3,
|
||||
"endsAt": "2026-05-10T20:15:30.000Z",
|
||||
"delaySeconds": 30
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|----------------|---------|--------------------------------------------------|
|
||||
| `sessionId` | number | The session the poll belongs to |
|
||||
| `endsAt` | string | ISO 8601 timestamp when the poll will auto-end |
|
||||
| `delaySeconds` | number | Original delay requested (seconds, 1-300) |
|
||||
|
||||
### poll.ending.cancelled
|
||||
|
||||
- **Broadcast to:** Clients subscribed to the session
|
||||
- **Triggered by:** `POST /api/sessions/:id/voting/cancel-end`
|
||||
|
||||
The scheduled poll end was cancelled by an admin. Clients should stop displaying the countdown and revert to the normal "Voting In Progress" state.
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"sessionId": 3
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Error Handling
|
||||
|
||||
84
docs/external-downstream-clients.md
Normal file
84
docs/external-downstream-clients.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Manual Poll Start — Upstream Integration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The vote-app now supports a `poll.start` WebSocket message that explicitly triggers poll generation. This replaces the previous behavior where polls were always auto-generated on `game.ended`.
|
||||
|
||||
## Poll Modes
|
||||
|
||||
The vote-app has two poll modes:
|
||||
|
||||
| Mode | `game.ended` behavior | `poll.start` behavior |
|
||||
|------|----------------------|----------------------|
|
||||
| **manual** (default) | Logged but no poll generated | Generates a new poll |
|
||||
| **auto** | Generates a new poll (legacy behavior) | Generates a new poll |
|
||||
|
||||
The mode is persisted in the vote-app database and survives restarts. It can be toggled from the debug panel (Game Control section).
|
||||
|
||||
## Message Format
|
||||
|
||||
Send this JSON message over the existing upstream WebSocket connection:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "poll.start",
|
||||
"sessionId": 3
|
||||
}
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `type` | string | yes | Must be `"poll.start"` |
|
||||
| `sessionId` | number | no | Included for consistency; the vote-app uses its internally tracked session ID |
|
||||
|
||||
The `sessionId` field is optional in practice — the vote-app tracks the active session from `session.started` / subscription events and uses that to determine which games to exclude from the poll. Including it is recommended for protocol consistency.
|
||||
|
||||
## When to Send
|
||||
|
||||
- **After `game.ended`** — if the vote-app is in `manual` mode, `game.ended` alone will not create a poll. Send `poll.start` when you're ready for viewers to vote on the next game.
|
||||
- **At any time** — you can send `poll.start` to force a new poll regardless of mode. Any existing active poll is deactivated and replaced.
|
||||
|
||||
## What Happens on Receipt
|
||||
|
||||
1. The vote-app fetches enabled games from the upstream API (`GET /api/games?enabled=true`)
|
||||
2. It fetches the current session's games (`GET /api/sessions/{id}/games`) to exclude recently played titles
|
||||
3. It picks 3 random games (weighted by favor bias) plus an "Other" option
|
||||
4. The previous active poll (if any) is deactivated
|
||||
5. The new poll is created and broadcast to all connected browser clients via WebSocket
|
||||
|
||||
## Example (Node.js)
|
||||
|
||||
```javascript
|
||||
// Assuming `ws` is your existing WebSocket connection to the vote-app upstream
|
||||
// and you've already authenticated and subscribed to the session
|
||||
|
||||
function startPoll(sessionId) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'poll.start',
|
||||
sessionId: sessionId
|
||||
}));
|
||||
}
|
||||
|
||||
// Typical flow: game ends, wait for the right moment, then start the poll
|
||||
ws.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw);
|
||||
|
||||
if (msg.type === 'game.ended') {
|
||||
// Game just ended — start poll when ready
|
||||
// (in manual mode, vote-app won't auto-generate one)
|
||||
startPoll(msg.data.sessionId);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Existing Messages (Unchanged)
|
||||
|
||||
These upstream messages continue to work as before:
|
||||
|
||||
- `game.ended` — in `auto` mode, still triggers poll generation; in `manual` mode, logged but no poll created
|
||||
- `voting.ended` — deactivates the active poll and broadcasts the winner
|
||||
- `game.started` — deactivates the active poll and hides the overlay
|
||||
- `room.connected` — deactivates the active poll and broadcasts room info
|
||||
- `poll.leading` — still sent by the vote-app to upstream when the leading vote changes
|
||||
@@ -5,6 +5,7 @@ import { ToastProvider } from './components/Toast';
|
||||
import { branding } from './config/branding';
|
||||
import Logo from './components/Logo';
|
||||
import ThemeToggle from './components/ThemeToggle';
|
||||
import StopwatchWidget from './components/StopwatchWidget';
|
||||
import InstallPrompt from './components/InstallPrompt';
|
||||
import SafariInstallPrompt from './components/SafariInstallPrompt';
|
||||
import PresenceBar from './components/PresenceBar';
|
||||
@@ -18,6 +19,7 @@ import SessionDetail from './pages/SessionDetail';
|
||||
function App() {
|
||||
const { isAuthenticated, logout } = useAuth();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [stopwatchVisible, setStopwatchVisible] = useState(false);
|
||||
|
||||
const closeMobileMenu = () => setMobileMenuOpen(false);
|
||||
|
||||
@@ -72,12 +74,37 @@ function App() {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Stopwatch Toggle */}
|
||||
<button
|
||||
onClick={() => setStopwatchVisible(v => !v)}
|
||||
title="Stopwatch"
|
||||
className="p-1.5 hover:bg-indigo-700 dark:hover:bg-indigo-900 rounded transition"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<circle cx="12" cy="13" r="8" />
|
||||
<path strokeLinecap="round" d="M12 9v4l2 2" />
|
||||
<path strokeLinecap="round" d="M12 5V3" />
|
||||
<path strokeLinecap="round" d="M10 3h4" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Theme Toggle */}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* Mobile: Theme Toggle and Hamburger */}
|
||||
{/* Mobile: Theme Toggle, Stopwatch, and Hamburger */}
|
||||
<div className="flex sm:hidden items-center gap-2">
|
||||
<button
|
||||
onClick={() => setStopwatchVisible(v => !v)}
|
||||
title="Stopwatch"
|
||||
className="p-1.5 hover:bg-indigo-700 dark:hover:bg-indigo-900 rounded transition"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<circle cx="12" cy="13" r="8" />
|
||||
<path strokeLinecap="round" d="M12 9v4l2 2" />
|
||||
<path strokeLinecap="round" d="M12 5V3" />
|
||||
<path strokeLinecap="round" d="M10 3h4" />
|
||||
</svg>
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
@@ -189,6 +216,9 @@ function App() {
|
||||
{/* PWA Install Prompts */}
|
||||
<InstallPrompt />
|
||||
<SafariInstallPrompt />
|
||||
|
||||
{/* Stopwatch Widget */}
|
||||
<StopwatchWidget visible={stopwatchVisible} onHide={() => setStopwatchVisible(false)} />
|
||||
</div>
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
482
frontend/src/components/StopwatchWidget.jsx
Normal file
482
frontend/src/components/StopwatchWidget.jsx
Normal file
@@ -0,0 +1,482 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
function useMediaQuery(query) {
|
||||
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(query);
|
||||
const handler = (e) => setMatches(e.matches);
|
||||
mql.addEventListener('change', handler);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
}, [query]);
|
||||
return matches;
|
||||
}
|
||||
|
||||
function formatTime(ms, showCentiseconds = true) {
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = Math.floor((ms % 60000) / 1000);
|
||||
if (!showCentiseconds) {
|
||||
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
const cs = Math.floor((ms % 1000) / 10);
|
||||
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}:${String(cs).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const COUNTDOWN_PRESETS = [
|
||||
{ label: '1:00', ms: 60000 },
|
||||
{ label: '2:00', ms: 120000 },
|
||||
{ label: '3:00', ms: 180000 },
|
||||
{ label: '5:00', ms: 300000 },
|
||||
];
|
||||
|
||||
const ALARM_SOUNDS = [
|
||||
{
|
||||
id: 'digital',
|
||||
label: 'Digital Beep',
|
||||
play: () => {
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const beep = (startTime) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.frequency.value = 830;
|
||||
osc.type = 'square';
|
||||
gain.gain.value = 0.3;
|
||||
osc.start(startTime);
|
||||
osc.stop(startTime + 0.15);
|
||||
};
|
||||
beep(ctx.currentTime);
|
||||
beep(ctx.currentTime + 0.25);
|
||||
beep(ctx.currentTime + 0.5);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'chime',
|
||||
label: 'Gentle Chime',
|
||||
play: () => {
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
[523, 659, 784].forEach((freq, i) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.frequency.value = freq;
|
||||
osc.type = 'sine';
|
||||
gain.gain.setValueAtTime(0.25, ctx.currentTime + i * 0.3);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + i * 0.3 + 0.4);
|
||||
osc.start(ctx.currentTime + i * 0.3);
|
||||
osc.stop(ctx.currentTime + i * 0.3 + 0.4);
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'urgent',
|
||||
label: 'Urgent Alarm',
|
||||
play: () => {
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.frequency.value = 1000;
|
||||
osc.type = 'sawtooth';
|
||||
gain.gain.value = 0.2;
|
||||
osc.start(ctx.currentTime + i * 0.12);
|
||||
osc.stop(ctx.currentTime + i * 0.12 + 0.08);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'bell',
|
||||
label: 'Bell',
|
||||
play: () => {
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.frequency.value = 660;
|
||||
osc.type = 'sine';
|
||||
gain.gain.setValueAtTime(0.4, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 1.5);
|
||||
osc.start(ctx.currentTime);
|
||||
osc.stop(ctx.currentTime + 1.5);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const inputClass = 'w-10 bg-gray-800 border border-gray-600 rounded px-1 py-0.5 text-center text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none';
|
||||
|
||||
export default function StopwatchWidget({ visible, onHide }) {
|
||||
const isDesktop = useMediaQuery('(min-width: 640px)');
|
||||
|
||||
const [mode, setMode] = useState('timer');
|
||||
|
||||
// Stopwatch state
|
||||
const [swRunning, setSwRunning] = useState(false);
|
||||
const [swElapsed, setSwElapsed] = useState(0);
|
||||
const [laps, setLaps] = useState([]);
|
||||
const swIntervalRef = useRef(null);
|
||||
const swStartRef = useRef(0);
|
||||
|
||||
// Countdown state
|
||||
const [cdTarget, setCdTarget] = useState(60000);
|
||||
const [cdRemaining, setCdRemaining] = useState(60000);
|
||||
const [cdRunning, setCdRunning] = useState(false);
|
||||
const [cdExpired, setCdExpired] = useState(false);
|
||||
const [cdInputMin, setCdInputMin] = useState('1');
|
||||
const [cdInputSec, setCdInputSec] = useState('00');
|
||||
const cdIntervalRef = useRef(null);
|
||||
const cdStartRef = useRef(0);
|
||||
const cdRemainingAtStartRef = useRef(0);
|
||||
|
||||
// Sound state
|
||||
const [soundEnabled, setSoundEnabled] = useState(true);
|
||||
const [selectedSound, setSelectedSound] = useState('digital');
|
||||
|
||||
// Drag state (desktop only)
|
||||
const [position, setPosition] = useState({ x: window.innerWidth - 300, y: 80 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
const cardRef = useRef(null);
|
||||
|
||||
// Stopwatch logic
|
||||
useEffect(() => {
|
||||
if (swRunning) {
|
||||
swStartRef.current = Date.now() - swElapsed;
|
||||
swIntervalRef.current = setInterval(() => {
|
||||
setSwElapsed(Date.now() - swStartRef.current);
|
||||
}, 10);
|
||||
} else {
|
||||
clearInterval(swIntervalRef.current);
|
||||
}
|
||||
return () => clearInterval(swIntervalRef.current);
|
||||
}, [swRunning]);
|
||||
|
||||
const handleSwStartStop = () => setSwRunning(r => !r);
|
||||
const handleSwReset = () => {
|
||||
setSwRunning(false);
|
||||
setSwElapsed(0);
|
||||
setLaps([]);
|
||||
};
|
||||
const handleSwLap = () => {
|
||||
setLaps(prev => [...prev, swElapsed]);
|
||||
};
|
||||
|
||||
// Countdown logic
|
||||
useEffect(() => {
|
||||
if (cdRunning) {
|
||||
cdStartRef.current = Date.now();
|
||||
cdRemainingAtStartRef.current = cdRemaining;
|
||||
cdIntervalRef.current = setInterval(() => {
|
||||
const elapsed = Date.now() - cdStartRef.current;
|
||||
const remaining = cdRemainingAtStartRef.current - elapsed;
|
||||
if (remaining <= 0) {
|
||||
setCdRemaining(0);
|
||||
setCdRunning(false);
|
||||
setCdExpired(true);
|
||||
clearInterval(cdIntervalRef.current);
|
||||
} else {
|
||||
setCdRemaining(remaining);
|
||||
}
|
||||
}, 10);
|
||||
} else {
|
||||
clearInterval(cdIntervalRef.current);
|
||||
}
|
||||
return () => clearInterval(cdIntervalRef.current);
|
||||
}, [cdRunning]);
|
||||
|
||||
const handleCdStartPause = () => {
|
||||
if (cdExpired) return;
|
||||
setCdRunning(r => !r);
|
||||
};
|
||||
const handleCdReset = () => {
|
||||
setCdRunning(false);
|
||||
setCdRemaining(cdTarget);
|
||||
setCdExpired(false);
|
||||
};
|
||||
const handleCdPreset = (ms) => {
|
||||
setCdRunning(false);
|
||||
setCdTarget(ms);
|
||||
setCdRemaining(ms);
|
||||
setCdExpired(false);
|
||||
setCdInputMin(String(Math.floor(ms / 60000)));
|
||||
setCdInputSec(String(Math.floor((ms % 60000) / 1000)).padStart(2, '0'));
|
||||
};
|
||||
const handleCdCustomSet = () => {
|
||||
const min = parseInt(cdInputMin) || 0;
|
||||
const sec = parseInt(cdInputSec) || 0;
|
||||
const ms = (min * 60 + sec) * 1000;
|
||||
if (ms > 0) {
|
||||
setCdTarget(ms);
|
||||
setCdRemaining(ms);
|
||||
setCdRunning(false);
|
||||
setCdExpired(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Expired flash + sound
|
||||
useEffect(() => {
|
||||
if (cdExpired) {
|
||||
if (soundEnabled) {
|
||||
const sound = ALARM_SOUNDS.find(s => s.id === selectedSound);
|
||||
if (sound) sound.play();
|
||||
}
|
||||
const timeout = setTimeout(() => setCdExpired(false), 3000);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [cdExpired, soundEnabled, selectedSound]);
|
||||
|
||||
const handlePreviewSound = () => {
|
||||
const sound = ALARM_SOUNDS.find(s => s.id === selectedSound);
|
||||
if (sound) sound.play();
|
||||
};
|
||||
|
||||
// Drag handlers (desktop)
|
||||
const handleDragStart = useCallback((e) => {
|
||||
if (!isDesktop) return;
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
||||
dragOffset.current = { x: clientX - position.x, y: clientY - position.y };
|
||||
setIsDragging(true);
|
||||
}, [isDesktop, position]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
const handleMove = (e) => {
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
||||
setPosition({
|
||||
x: Math.max(0, Math.min(window.innerWidth - 280, clientX - dragOffset.current.x)),
|
||||
y: Math.max(0, Math.min(window.innerHeight - 100, clientY - dragOffset.current.y)),
|
||||
});
|
||||
};
|
||||
const handleUp = () => setIsDragging(false);
|
||||
|
||||
document.addEventListener('mousemove', handleMove);
|
||||
document.addEventListener('mouseup', handleUp);
|
||||
document.addEventListener('touchmove', handleMove);
|
||||
document.addEventListener('touchend', handleUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMove);
|
||||
document.removeEventListener('mouseup', handleUp);
|
||||
document.removeEventListener('touchmove', handleMove);
|
||||
document.removeEventListener('touchend', handleUp);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const cardClasses = isDesktop
|
||||
? 'fixed z-50 w-[280px] rounded-xl shadow-2xl'
|
||||
: 'fixed z-50 bottom-0 left-0 right-0 rounded-t-xl shadow-2xl';
|
||||
|
||||
const cardStyle = isDesktop ? { left: position.x, top: position.y } : {};
|
||||
|
||||
const borderColor = cdExpired
|
||||
? 'border-red-500 animate-pulse'
|
||||
: 'border-gray-700 dark:border-gray-600';
|
||||
|
||||
const content = (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`${cardClasses} bg-gray-900 dark:bg-gray-950 border-2 ${borderColor} text-white select-none`}
|
||||
style={cardStyle}
|
||||
>
|
||||
{/* Drag handle / header */}
|
||||
<div
|
||||
className={`flex items-center justify-between px-3 py-2 ${isDesktop ? 'cursor-grab active:cursor-grabbing' : ''}`}
|
||||
onMouseDown={handleDragStart}
|
||||
onTouchStart={handleDragStart}
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setMode('stopwatch')}
|
||||
className={`px-2 py-1 text-xs rounded transition ${mode === 'stopwatch' ? 'bg-indigo-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}`}
|
||||
>
|
||||
Stopwatch
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('timer')}
|
||||
className={`px-2 py-1 text-xs rounded transition ${mode === 'timer' ? 'bg-indigo-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}`}
|
||||
>
|
||||
Timer
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onHide}
|
||||
className="w-7 h-7 sm:w-6 sm:h-6 flex items-center justify-center text-gray-400 hover:text-white hover:bg-gray-700 rounded transition"
|
||||
title="Hide"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Display */}
|
||||
<div className="px-3 pb-3">
|
||||
<div className="bg-black/40 rounded-lg px-4 py-3 sm:py-2 text-center mb-3">
|
||||
<span
|
||||
className={`font-mono tracking-wider ${isDesktop ? 'text-2xl' : 'text-3xl'} ${mode === 'timer' && cdRemaining < 10000 && cdRunning ? 'text-red-400' : 'text-green-400'}`}
|
||||
style={{ fontFamily: "'Courier New', monospace" }}
|
||||
>
|
||||
{mode === 'stopwatch'
|
||||
? formatTime(swElapsed)
|
||||
: formatTime(cdRemaining, false)
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
{mode === 'stopwatch' ? (
|
||||
<div className="flex gap-2 mb-2">
|
||||
<button
|
||||
onClick={handleSwStartStop}
|
||||
className={`flex-1 py-2 sm:py-1.5 rounded text-sm font-semibold transition ${swRunning ? 'bg-red-600 hover:bg-red-700' : 'bg-green-600 hover:bg-green-700'}`}
|
||||
>
|
||||
{swRunning ? 'Stop' : 'Start'}
|
||||
</button>
|
||||
{swRunning && (
|
||||
<button
|
||||
onClick={handleSwLap}
|
||||
className="flex-1 py-2 sm:py-1.5 rounded text-sm font-semibold bg-blue-600 hover:bg-blue-700 transition"
|
||||
>
|
||||
Lap
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSwReset}
|
||||
className="flex-1 py-2 sm:py-1.5 rounded text-sm font-semibold bg-gray-600 hover:bg-gray-700 transition"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<button
|
||||
onClick={handleCdStartPause}
|
||||
disabled={cdExpired || cdRemaining <= 0}
|
||||
className={`flex-1 py-2 sm:py-1.5 rounded text-sm font-semibold transition disabled:opacity-50 ${cdRunning ? 'bg-yellow-600 hover:bg-yellow-700' : 'bg-green-600 hover:bg-green-700'}`}
|
||||
>
|
||||
{cdRunning ? 'Pause' : 'Start'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCdReset}
|
||||
className="flex-1 py-2 sm:py-1.5 rounded text-sm font-semibold bg-gray-600 hover:bg-gray-700 transition"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
{!cdRunning && (
|
||||
<>
|
||||
<div className="flex gap-1 mb-2">
|
||||
{COUNTDOWN_PRESETS.map(p => (
|
||||
<button
|
||||
key={p.ms}
|
||||
onClick={() => handleCdPreset(p.ms)}
|
||||
className={`flex-1 py-1 rounded text-xs font-medium transition ${cdTarget === p.ms ? 'bg-indigo-600' : 'bg-gray-700 hover:bg-gray-600'}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="99"
|
||||
value={cdInputMin}
|
||||
onChange={(e) => setCdInputMin(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
<span className="text-gray-400">m</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
value={cdInputSec}
|
||||
onChange={(e) => setCdInputSec(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
<span className="text-gray-400">s</span>
|
||||
<button
|
||||
onClick={handleCdCustomSet}
|
||||
className="ml-1 px-2 py-0.5 bg-indigo-600 hover:bg-indigo-700 rounded text-xs font-medium transition"
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Sound controls */}
|
||||
<div className="flex items-center gap-2 mt-3 pt-2 border-t border-gray-700/50">
|
||||
<button
|
||||
onClick={() => setSoundEnabled(s => !s)}
|
||||
className={`p-1 rounded transition ${soundEnabled ? 'text-indigo-400 hover:text-indigo-300' : 'text-gray-500 hover:text-gray-400'}`}
|
||||
title={soundEnabled ? 'Mute alarm' : 'Enable alarm sound'}
|
||||
>
|
||||
{soundEnabled ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.536 8.464a5 5 0 010 7.072M17.95 6.05a8 8 0 010 11.9M6.5 8H4a1 1 0 00-1 1v6a1 1 0 001 1h2.5l4.5 4V4l-4.5 4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707A1 1 0 0112 5v14a1 1 0 01-1.707.707L5.586 15z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<div className="relative flex-1">
|
||||
<select
|
||||
value={selectedSound}
|
||||
onChange={(e) => setSelectedSound(e.target.value)}
|
||||
disabled={!soundEnabled}
|
||||
className="w-full appearance-none bg-gray-800 border border-gray-600 rounded px-2 py-1 pr-6 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500 disabled:opacity-40 disabled:cursor-not-allowed transition"
|
||||
>
|
||||
{ALARM_SOUNDS.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<svg className="absolute right-1.5 top-1/2 -translate-y-1/2 w-3 h-3 text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<button
|
||||
onClick={handlePreviewSound}
|
||||
disabled={!soundEnabled}
|
||||
className="p-1 rounded text-gray-400 hover:text-white transition disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Preview sound"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Lap list (stopwatch mode) */}
|
||||
{mode === 'stopwatch' && laps.length > 0 && (
|
||||
<div className={`mt-2 border-t border-gray-700 pt-2 overflow-y-auto ${isDesktop ? 'max-h-32' : 'max-h-24'}`}>
|
||||
{laps.map((lapTime, i) => {
|
||||
const prev = i === 0 ? 0 : laps[i - 1];
|
||||
const delta = lapTime - prev;
|
||||
return (
|
||||
<div key={i} className="flex justify-between text-xs text-gray-300 py-0.5">
|
||||
<span className="text-gray-500">#{i + 1}</span>
|
||||
<span className="font-mono">{formatTime(delta)}</span>
|
||||
<span className="font-mono text-gray-500">{formatTime(lapTime)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(content, document.body);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ export const branding = {
|
||||
app: {
|
||||
name: 'HSO Jackbox Game Picker',
|
||||
shortName: 'Jackbox Game Picker',
|
||||
version: '0.7.0 - Fixed For Real Edition',
|
||||
version: '0.7.13 - Pokémon-Go-To-The-Polls Edition',
|
||||
description: 'Spicing up Hyper Spaceout game nights!',
|
||||
},
|
||||
meta: {
|
||||
|
||||
@@ -161,10 +161,15 @@ function Home() {
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
({game.pack_name})
|
||||
{game.manually_added === 1 && (
|
||||
{game.source === 'manual' || (game.manually_added === 1 && game.source !== 'poll') ? (
|
||||
<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>
|
||||
) : null}
|
||||
{game.source === 'poll' && (
|
||||
<span className="ml-2 text-xs bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200 px-2 py-1 rounded">
|
||||
Poll
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,19 @@ function Picker() {
|
||||
const [activeSession, setActiveSession] = useState(null);
|
||||
const [allGames, setAllGames] = useState([]);
|
||||
const [selectedGame, setSelectedGame] = useState(null);
|
||||
const [playingGame, setPlayingGame] = useState(null); // Currently playing game
|
||||
const [playingGame, setPlayingGame] = useState(null);
|
||||
const [hasPlayedGames, setHasPlayedGames] = useState(false);
|
||||
const [leadingGame, setLeadingGame] = useState(null);
|
||||
const [pollActive, setPollActive] = useState(false);
|
||||
const [pollResult, setPollResult] = useState(null);
|
||||
const [pollElapsed, setPollElapsed] = useState(0);
|
||||
const pollTimerRef = useRef(null);
|
||||
const pollStartedAtRef = useRef(null);
|
||||
const [pollEndingAt, setPollEndingAt] = useState(null);
|
||||
const [pollCountdown, setPollCountdown] = useState(null);
|
||||
const pollCountdownRef = useRef(null);
|
||||
const [showEndPollOptions, setShowEndPollOptions] = useState(false);
|
||||
const [customDelay, setCustomDelay] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [picking, setPicking] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -92,8 +104,33 @@ function Picker() {
|
||||
const gamesResponse = await api.get('/games?enabled=true');
|
||||
setAllGames(gamesResponse.data);
|
||||
|
||||
// Load currently playing game if session exists
|
||||
// Load currently playing game and restore poll state if session exists
|
||||
if (session && session.id) {
|
||||
// Restore poll state from persisted session data
|
||||
if (session.poll_active) {
|
||||
pollStartedAtRef.current = session.poll_started_at || null;
|
||||
setPollActive(true);
|
||||
if (session.poll_leading_game_id) {
|
||||
setLeadingGame({
|
||||
gameId: session.poll_leading_game_id,
|
||||
label: session.poll_leading_label,
|
||||
votes: session.poll_leading_votes,
|
||||
});
|
||||
}
|
||||
if (session.poll_ending_at && new Date(session.poll_ending_at) > new Date()) {
|
||||
setPollEndingAt(session.poll_ending_at);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore pending game selection if another admin picked one
|
||||
if (session.pending_game_id) {
|
||||
const pendingGame = gamesResponse.data.find(g => g.id === session.pending_game_id);
|
||||
if (pendingGame) {
|
||||
setSelectedGame(pendingGame);
|
||||
setGameSource(session.pending_game_source || 'dice');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionGamesResponse = await api.get(`/sessions/${session.id}/games`);
|
||||
const playingGameEntry = sessionGamesResponse.data.find(g => g.status === 'playing');
|
||||
@@ -102,6 +139,7 @@ function Picker() {
|
||||
} else {
|
||||
setPlayingGame(null);
|
||||
}
|
||||
setHasPlayedGames(sessionGamesResponse.data.some(g => g.status === 'played'));
|
||||
} catch (err) {
|
||||
console.error('Failed to load playing game', err);
|
||||
}
|
||||
@@ -147,6 +185,52 @@ function Picker() {
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showManualDropdown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pollActive) {
|
||||
const start = pollStartedAtRef.current
|
||||
? new Date(pollStartedAtRef.current).getTime()
|
||||
: Date.now();
|
||||
pollTimerRef.current = setInterval(() => {
|
||||
setPollElapsed(Date.now() - start);
|
||||
}, 10);
|
||||
} else {
|
||||
clearInterval(pollTimerRef.current);
|
||||
setPollElapsed(0);
|
||||
pollStartedAtRef.current = null;
|
||||
}
|
||||
return () => clearInterval(pollTimerRef.current);
|
||||
}, [pollActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pollEndingAt) {
|
||||
const tick = () => {
|
||||
const remaining = Math.max(0, Math.ceil((new Date(pollEndingAt).getTime() - Date.now()) / 1000));
|
||||
setPollCountdown(remaining);
|
||||
if (remaining <= 0) {
|
||||
clearInterval(pollCountdownRef.current);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
pollCountdownRef.current = setInterval(tick, 250);
|
||||
return () => clearInterval(pollCountdownRef.current);
|
||||
}
|
||||
clearInterval(pollCountdownRef.current);
|
||||
setPollCountdown(null);
|
||||
}, [pollEndingAt]);
|
||||
|
||||
const formatElapsed = (ms) => {
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = Math.floor((ms % 60000) / 1000);
|
||||
const centiseconds = Math.floor((ms % 1000) / 10);
|
||||
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}:${String(centiseconds).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const formatCountdown = (totalSeconds) => {
|
||||
const m = Math.floor(totalSeconds / 60);
|
||||
const s = totalSeconds % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleCreateSession = async () => {
|
||||
try {
|
||||
const newSession = await api.post('/sessions', {});
|
||||
@@ -158,6 +242,97 @@ function Picker() {
|
||||
}
|
||||
};
|
||||
|
||||
const leadingGameRef = useRef(leadingGame);
|
||||
leadingGameRef.current = leadingGame;
|
||||
|
||||
const pollActiveRef = useRef(pollActive);
|
||||
pollActiveRef.current = pollActive;
|
||||
|
||||
const handleStartPolling = async () => {
|
||||
pollStartedAtRef.current = new Date().toISOString();
|
||||
setPollActive(true);
|
||||
setPollResult(null);
|
||||
setPollEndingAt(null);
|
||||
setShowEndPollOptions(false);
|
||||
try {
|
||||
await api.post(`/sessions/${activeSession.id}/voting/start`);
|
||||
} catch (err) {
|
||||
console.error('Failed to start polling', err);
|
||||
setPollActive(false);
|
||||
pollStartedAtRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndPolling = async (delay = 0) => {
|
||||
setShowEndPollOptions(false);
|
||||
setCustomDelay('');
|
||||
|
||||
if (delay === 0) {
|
||||
const winner = leadingGameRef.current;
|
||||
setPollActive(false);
|
||||
setLeadingGame(null);
|
||||
setPollEndingAt(null);
|
||||
if (winner) {
|
||||
setPollResult(winner);
|
||||
}
|
||||
try {
|
||||
await api.post(`/sessions/${activeSession.id}/voting/end`, { delay: 0 });
|
||||
} catch (err) {
|
||||
console.error('Failed to end polling', err);
|
||||
setPollActive(true);
|
||||
setLeadingGame(winner);
|
||||
setPollResult(null);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const res = await api.post(`/sessions/${activeSession.id}/voting/end`, { delay });
|
||||
setPollEndingAt(res.data.endsAt);
|
||||
} catch (err) {
|
||||
console.error('Failed to schedule poll end', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelPollEnd = async () => {
|
||||
setPollEndingAt(null);
|
||||
try {
|
||||
await api.post(`/sessions/${activeSession.id}/voting/cancel-end`);
|
||||
} catch (err) {
|
||||
console.error('Failed to cancel poll end', err);
|
||||
}
|
||||
};
|
||||
|
||||
const [gameSource, setGameSource] = useState('dice');
|
||||
|
||||
const handleUsePollResult = () => {
|
||||
if (pollResult) {
|
||||
const game = allGames.find(g => g.id === pollResult.gameId);
|
||||
if (game) {
|
||||
setSelectedGame(game);
|
||||
setGameSource('poll');
|
||||
if (activeSession) {
|
||||
api.post(`/sessions/${activeSession.id}/game-selection`, {
|
||||
game_id: game.id,
|
||||
source: 'poll'
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
setPollResult(null);
|
||||
};
|
||||
|
||||
const handleIgnorePollResult = () => {
|
||||
setPollResult(null);
|
||||
};
|
||||
|
||||
const handleDismissGame = () => {
|
||||
setSelectedGame(null);
|
||||
setGameSource('dice');
|
||||
if (activeSession) {
|
||||
api.delete(`/sessions/${activeSession.id}/game-selection`).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const loadEligibleGames = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
@@ -219,9 +394,15 @@ function Picker() {
|
||||
});
|
||||
|
||||
setSelectedGame(response.data.game);
|
||||
setGameSource('dice');
|
||||
api.post(`/sessions/${activeSession.id}/game-selection`, {
|
||||
game_id: response.data.game.id,
|
||||
source: 'dice'
|
||||
}).catch(() => {});
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to pick a game');
|
||||
setSelectedGame(null);
|
||||
setGameSource('dice');
|
||||
} finally {
|
||||
setPicking(false);
|
||||
}
|
||||
@@ -233,7 +414,8 @@ function Picker() {
|
||||
// Show room code modal
|
||||
setPendingGameAction({
|
||||
type: 'accept',
|
||||
game: selectedGame
|
||||
game: selectedGame,
|
||||
source: gameSource
|
||||
});
|
||||
setShowRoomCodeModal(true);
|
||||
};
|
||||
@@ -242,13 +424,14 @@ function Picker() {
|
||||
if (!pendingGameAction || !activeSession) return;
|
||||
|
||||
try {
|
||||
const { type, game, gameId } = pendingGameAction;
|
||||
const { type, game, gameId, source } = pendingGameAction;
|
||||
|
||||
if (type === 'accept' || type === 'version') {
|
||||
const response = await api.post(`/sessions/${activeSession.id}/games`, {
|
||||
game_id: gameId || game.id,
|
||||
manually_added: false,
|
||||
room_code: roomCode
|
||||
room_code: roomCode,
|
||||
source: source || 'dice'
|
||||
});
|
||||
// Set the newly added game as playing
|
||||
setPlayingGame(response.data);
|
||||
@@ -256,7 +439,8 @@ function Picker() {
|
||||
const response = await api.post(`/sessions/${activeSession.id}/games`, {
|
||||
game_id: gameId,
|
||||
manually_added: true,
|
||||
room_code: roomCode
|
||||
room_code: roomCode,
|
||||
source: 'manual'
|
||||
});
|
||||
setManualGameId('');
|
||||
setShowManualSelect(false);
|
||||
@@ -266,6 +450,7 @@ function Picker() {
|
||||
|
||||
// Close all modals and clear selected game after adding to session
|
||||
setSelectedGame(null);
|
||||
setGameSource('dice');
|
||||
setShowGamePool(false);
|
||||
|
||||
// Trigger games list refresh
|
||||
@@ -687,6 +872,25 @@ function Picker() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Poll Leader Indicator */}
|
||||
{leadingGame && (
|
||||
<div className="bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800 rounded-lg p-3 sm:p-4 mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-indigo-500 dark:text-indigo-400 uppercase tracking-wide">
|
||||
Poll Leader
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-indigo-800 dark:text-indigo-200">
|
||||
{leadingGame.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-indigo-500 dark:text-indigo-400">
|
||||
{leadingGame.votes} votes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Currently Playing Game Card */}
|
||||
{playingGame && (
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border-2 border-green-500 dark:border-green-700 rounded-lg shadow-lg p-4 sm:p-8 mb-6">
|
||||
@@ -744,12 +948,161 @@ function Picker() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Poll Control Card */}
|
||||
{pollResult ? (
|
||||
<div className="bg-indigo-50 dark:bg-indigo-900/20 border-2 border-indigo-400 dark:border-indigo-700 rounded-lg shadow-lg p-4 sm:p-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-indigo-800 dark:text-indigo-200">
|
||||
Poll Winner: {pollResult.label}
|
||||
</h3>
|
||||
<p className="text-sm text-indigo-600 dark:text-indigo-400 mt-1">
|
||||
{pollResult.votes} votes — Use as the next game?
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleUsePollResult}
|
||||
className="bg-green-600 dark:bg-green-700 text-white px-4 py-2 rounded-lg hover:bg-green-700 dark:hover:bg-green-800 transition font-semibold text-sm whitespace-nowrap"
|
||||
>
|
||||
Use as Choice
|
||||
</button>
|
||||
<button
|
||||
onClick={handleIgnorePollResult}
|
||||
className="bg-gray-500 dark:bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-600 dark:hover:bg-gray-700 transition font-semibold text-sm whitespace-nowrap"
|
||||
>
|
||||
Ignore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : pollActive && pollEndingAt ? (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border-2 border-red-400 dark:border-red-700 rounded-lg shadow-lg p-4 sm:p-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-red-800 dark:text-red-200">
|
||||
Poll Ending
|
||||
</h3>
|
||||
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
|
||||
Voting will close automatically.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-sm text-red-700 dark:text-red-300 font-semibold">Ending in</span>
|
||||
<span className="font-mono text-2xl tracking-wider text-red-800 dark:text-red-100 bg-red-200/60 dark:bg-red-800/40 px-4 py-2 rounded" style={{ fontFamily: "'Courier New', monospace" }}>
|
||||
{pollCountdown !== null ? formatCountdown(pollCountdown) : '--:--'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCancelPollEnd}
|
||||
className="bg-gray-500 dark:bg-gray-600 text-white px-4 py-3 rounded-lg hover:bg-gray-600 dark:hover:bg-gray-700 transition font-semibold text-sm whitespace-nowrap"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : pollActive ? (
|
||||
<div className="bg-orange-50 dark:bg-orange-900/20 border-2 border-orange-400 dark:border-orange-700 rounded-lg shadow-lg p-4 sm:p-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-orange-800 dark:text-orange-200">
|
||||
Voting In Progress
|
||||
</h3>
|
||||
<p className="text-sm text-orange-600 dark:text-orange-400 mt-1">
|
||||
End the current poll when ready to pick the next game.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowEndPollOptions(prev => !prev)}
|
||||
className="bg-orange-600 dark:bg-orange-700 text-white px-6 py-4 rounded-lg hover:bg-orange-700 dark:hover:bg-orange-800 transition font-semibold whitespace-nowrap flex flex-col items-center gap-1"
|
||||
>
|
||||
<span className="text-sm">End Poll</span>
|
||||
<span className="font-mono text-lg tracking-wider bg-black/20 px-3 py-1 rounded" style={{ fontFamily: "'Courier New', monospace" }}>
|
||||
{formatElapsed(pollElapsed)}
|
||||
</span>
|
||||
</button>
|
||||
{showEndPollOptions && (
|
||||
<div className="absolute right-0 top-full mt-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl z-50 w-56 py-1">
|
||||
<button
|
||||
onClick={() => handleEndPolling(0)}
|
||||
className="w-full text-left px-4 py-2.5 text-sm font-semibold text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition"
|
||||
>
|
||||
End Now
|
||||
</button>
|
||||
<hr className="border-gray-200 dark:border-gray-700 my-1" />
|
||||
{[5, 10, 30].map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => handleEndPolling(s)}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition"
|
||||
>
|
||||
{s} seconds
|
||||
</button>
|
||||
))}
|
||||
<hr className="border-gray-200 dark:border-gray-700 my-1" />
|
||||
<div className="px-4 py-2 flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="300"
|
||||
placeholder="sec"
|
||||
value={customDelay}
|
||||
onChange={e => setCustomDelay(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
const val = parseInt(customDelay);
|
||||
if (val >= 1 && val <= 300) handleEndPolling(val);
|
||||
}
|
||||
}}
|
||||
className="w-20 px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const val = parseInt(customDelay);
|
||||
if (val >= 1 && val <= 300) handleEndPolling(val);
|
||||
}}
|
||||
disabled={!customDelay || parseInt(customDelay) < 1 || parseInt(customDelay) > 300}
|
||||
className="px-3 py-1.5 text-sm bg-orange-600 dark:bg-orange-700 text-white rounded hover:bg-orange-700 dark:hover:bg-orange-800 transition disabled:opacity-40 disabled:cursor-not-allowed font-semibold"
|
||||
>
|
||||
Go
|
||||
</button>
|
||||
<span className="text-xs text-gray-400">max 5m</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border-2 border-green-400 dark:border-green-700 rounded-lg shadow-lg p-4 sm:p-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-green-800 dark:text-green-200">
|
||||
Ready to Vote
|
||||
</h3>
|
||||
<p className="text-sm text-green-600 dark:text-green-400 mt-1">
|
||||
Start a new poll for the next game.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleStartPolling}
|
||||
className="bg-green-600 dark:bg-green-700 text-white px-5 py-3 rounded-lg hover:bg-green-700 dark:hover:bg-green-800 transition font-semibold text-sm whitespace-nowrap"
|
||||
>
|
||||
Start Poll
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected Game Card (from dice roll) */}
|
||||
{selectedGame && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-8 mb-6 relative">
|
||||
{/* Close/Dismiss Button */}
|
||||
<button
|
||||
onClick={() => setSelectedGame(null)}
|
||||
onClick={handleDismissGame}
|
||||
className="absolute top-2 right-2 sm:top-4 sm:right-4 w-8 h-8 flex items-center justify-center text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition"
|
||||
title="Dismiss"
|
||||
>
|
||||
@@ -822,7 +1175,7 @@ function Picker() {
|
||||
🎲 Re-roll
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedGame(null)}
|
||||
onClick={handleDismissGame}
|
||||
className="bg-gray-500 dark:bg-gray-600 text-white px-4 py-3 rounded-lg hover:bg-gray-600 dark:hover:bg-gray-700 transition font-semibold"
|
||||
title="Cancel"
|
||||
>
|
||||
@@ -931,6 +1284,16 @@ function Picker() {
|
||||
onGamesUpdate={gamesUpdateTrigger}
|
||||
playingGame={playingGame}
|
||||
setPlayingGame={setPlayingGame}
|
||||
setHasPlayedGames={setHasPlayedGames}
|
||||
setLeadingGame={setLeadingGame}
|
||||
setPollActive={setPollActive}
|
||||
pollActiveRef={pollActiveRef}
|
||||
setPollResult={setPollResult}
|
||||
setPollEndingAt={setPollEndingAt}
|
||||
setShowEndPollOptions={setShowEndPollOptions}
|
||||
pollStartedAtRef={pollStartedAtRef}
|
||||
setSelectedGame={setSelectedGame}
|
||||
setGameSource={setGameSource}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -938,7 +1301,7 @@ function Picker() {
|
||||
);
|
||||
}
|
||||
|
||||
function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame }) {
|
||||
function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame, setHasPlayedGames, setLeadingGame, setPollActive, pollActiveRef, setPollResult, setPollEndingAt, setShowEndPollOptions, pollStartedAtRef, setSelectedGame, setGameSource }) {
|
||||
const { isAuthenticated, token } = useAuth();
|
||||
const [games, setGames] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -973,6 +1336,8 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
|
||||
setPlayingGame(null);
|
||||
}
|
||||
}
|
||||
|
||||
setHasPlayedGames(freshGames.some(g => g.status === 'played'));
|
||||
} catch (err) {
|
||||
console.error('Failed to load session games');
|
||||
} finally {
|
||||
@@ -993,28 +1358,40 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
|
||||
return () => clearInterval(interval);
|
||||
}, [loadGames]);
|
||||
|
||||
// Setup WebSocket connection for real-time session updates
|
||||
useEffect(() => {
|
||||
// Setup WebSocket connection for real-time session updates (with ping + auto-reconnect)
|
||||
const wsRef = useRef(null);
|
||||
const pingIntervalRef = useRef(null);
|
||||
const reconnectTimeoutRef = useRef(null);
|
||||
|
||||
const connectWs = useCallback(() => {
|
||||
if (!token) return;
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.hostname}:${window.location.port || (window.location.protocol === 'https:' ? 443 : 80)}/api/sessions/live`;
|
||||
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[WebSocket] Connected, authenticating...');
|
||||
ws.send(JSON.stringify({ type: 'auth', token }));
|
||||
};
|
||||
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
|
||||
if (message.type === 'auth_success') {
|
||||
console.log('[WebSocket] Authenticated, subscribing to session', sessionId);
|
||||
ws.send(JSON.stringify({ type: 'subscribe', sessionId: parseInt(sessionId) }));
|
||||
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 30000);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1030,34 +1407,108 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
|
||||
'game.status',
|
||||
];
|
||||
|
||||
if (message.type === 'poll.start') {
|
||||
pollStartedAtRef.current = message.data.pollStartedAt || new Date().toISOString();
|
||||
setPollActive(true);
|
||||
setPollResult(null);
|
||||
setLeadingGame(null);
|
||||
setPollEndingAt(null);
|
||||
setShowEndPollOptions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'poll.ending') {
|
||||
setPollEndingAt(message.data.endsAt);
|
||||
setShowEndPollOptions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'poll.ending.cancelled') {
|
||||
setPollEndingAt(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'poll.leading') {
|
||||
if (pollActiveRef.current) {
|
||||
setLeadingGame(message.data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'voting.ended') {
|
||||
setLeadingGame(null);
|
||||
setPollActive(false);
|
||||
setPollEndingAt(null);
|
||||
setShowEndPollOptions(false);
|
||||
if (message.data.winnerGameId) {
|
||||
setPollResult({
|
||||
gameId: message.data.winnerGameId,
|
||||
label: message.data.winnerLabel,
|
||||
votes: message.data.winnerVotes
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (message.type === 'game.started') {
|
||||
setLeadingGame(null);
|
||||
setPollActive(false);
|
||||
setPollEndingAt(null);
|
||||
}
|
||||
|
||||
if (message.type === 'game.picked') {
|
||||
setSelectedGame(message.data.game);
|
||||
setGameSource(message.data.source || 'dice');
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'game.dismissed') {
|
||||
setSelectedGame(null);
|
||||
setGameSource('dice');
|
||||
return;
|
||||
}
|
||||
|
||||
if (reloadEvents.includes(message.type)) {
|
||||
console.log(`[WebSocket] ${message.type}:`, message.data);
|
||||
if (message.type === 'game.added') {
|
||||
setSelectedGame(null);
|
||||
setGameSource('dice');
|
||||
}
|
||||
loadGames();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[WebSocket] Error parsing message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('[WebSocket] Error:', error);
|
||||
};
|
||||
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[WebSocket] Disconnected');
|
||||
console.log('[WebSocket] Disconnected, reconnecting in 3s...');
|
||||
clearInterval(pingIntervalRef.current);
|
||||
reconnectTimeoutRef.current = setTimeout(connectWs, 3000);
|
||||
};
|
||||
|
||||
|
||||
setWsConnection(ws);
|
||||
|
||||
return () => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[WebSocket] Failed to connect:', error);
|
||||
reconnectTimeoutRef.current = setTimeout(connectWs, 3000);
|
||||
}
|
||||
}, [sessionId, token, loadGames]);
|
||||
}, [sessionId, token, loadGames, setPollActive, setPollResult, setPollEndingAt, setShowEndPollOptions, setLeadingGame, pollActiveRef, pollStartedAtRef, setSelectedGame, setGameSource]);
|
||||
|
||||
useEffect(() => {
|
||||
connectWs();
|
||||
|
||||
return () => {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
clearInterval(pingIntervalRef.current);
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [connectWs]);
|
||||
|
||||
const handleUpdateStatus = async (gameId, newStatus) => {
|
||||
try {
|
||||
@@ -1283,10 +1734,15 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
|
||||
{displayNumber}. {game.title}
|
||||
</span>
|
||||
{getStatusBadge(game.status)}
|
||||
{game.manually_added === 1 && (
|
||||
{game.source === 'manual' || (game.manually_added === 1 && game.source !== 'poll') ? (
|
||||
<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>
|
||||
</span>
|
||||
) : null}
|
||||
{game.source === 'poll' && (
|
||||
<span className="text-xs bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200 px-2 py-1 rounded">
|
||||
Poll
|
||||
</span>
|
||||
)}
|
||||
{game.room_code && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
|
||||
@@ -320,10 +320,15 @@ function SessionDetail() {
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatLocalTime(game.played_at)}
|
||||
</div>
|
||||
{game.manually_added === 1 && (
|
||||
{game.source === 'manual' || (game.manually_added === 1 && game.source !== 'poll') ? (
|
||||
<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
|
||||
</span>
|
||||
) : null}
|
||||
{game.source === 'poll' && (
|
||||
<span className="inline-block mt-1 text-xs bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200 px-2 py-1 rounded">
|
||||
Poll
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,3 +58,4 @@ Jackbox Naughty Pack,Fakin’ It All Night Long,3,8,????,?,No,?,?
|
||||
Jackbox Naughty Pack,Dirty Drawful,3,8,????,?,No,?,?
|
||||
Jackbox Naughty Pack,Let Me Finish,3,8,????,?,No,?,?
|
||||
Jackbox Party Pack,Survey Scramble,2,10,????,Yes,Yes,?,?
|
||||
Jackbox Party Pack Playtests,Trivia Murder Party 3 (Playtest),1,8,????,?,No,Trivia,
|
||||
|
||||
|
Reference in New Issue
Block a user