pretty much ready to 'ship'

This commit is contained in:
cottongin
2025-10-30 17:18:30 -04:00
parent 7bb3aabd72
commit 8f3a12ad76
10 changed files with 518 additions and 190 deletions

View File

@@ -84,6 +84,46 @@ function initializeDatabase() {
// Column already exists, ignore error
}
// Add upvotes and downvotes columns to games if they don't exist
try {
db.exec(`ALTER TABLE games ADD COLUMN upvotes INTEGER DEFAULT 0`);
} catch (err) {
// Column already exists, ignore error
}
try {
db.exec(`ALTER TABLE games ADD COLUMN downvotes INTEGER DEFAULT 0`);
} catch (err) {
// Column already exists, ignore error
}
// Migrate existing popularity_score to upvotes/downvotes if needed
try {
const gamesWithScore = db.prepare(`
SELECT id, popularity_score FROM games
WHERE popularity_score != 0 AND (upvotes = 0 AND downvotes = 0)
`).all();
if (gamesWithScore.length > 0) {
const updateGame = db.prepare(`
UPDATE games
SET upvotes = ?, downvotes = ?
WHERE id = ?
`);
for (const game of gamesWithScore) {
if (game.popularity_score > 0) {
updateGame.run(game.popularity_score, 0, game.id);
} else {
updateGame.run(0, Math.abs(game.popularity_score), game.id);
}
}
console.log(`Migrated popularity scores for ${gamesWithScore.length} games`);
}
} catch (err) {
console.error('Error migrating popularity scores:', err);
}
// Packs table for pack-level favoriting
db.exec(`
CREATE TABLE IF NOT EXISTS packs (
@@ -113,6 +153,20 @@ function initializeDatabase() {
)
`);
// Add message_hash column if it doesn't exist
try {
db.exec(`ALTER TABLE chat_logs ADD COLUMN message_hash TEXT`);
} catch (err) {
// Column already exists, ignore error
}
// Create index on message_hash for fast duplicate checking
try {
db.exec(`CREATE INDEX IF NOT EXISTS idx_chat_logs_hash ON chat_logs(message_hash)`);
} catch (err) {
// Index already exists, ignore error
}
console.log('Database initialized successfully');
}

View File

@@ -1,9 +1,18 @@
const express = require('express');
const crypto = require('crypto');
const { authenticateToken } = require('../middleware/auth');
const db = require('../database');
const router = express.Router();
// Helper function to create a hash of a message
function createMessageHash(username, message, timestamp) {
return crypto
.createHash('sha256')
.update(`${username}:${message}:${timestamp}`)
.digest('hex');
}
// Get all sessions
router.get('/', (req, res) => {
try {
@@ -175,7 +184,9 @@ router.get('/:id/games', (req, res) => {
g.game_type,
g.min_players,
g.max_players,
g.popularity_score
g.popularity_score,
g.upvotes,
g.downvotes
FROM session_games sg
JOIN games g ON sg.game_id = g.id
WHERE sg.session_id = ?
@@ -270,7 +281,7 @@ router.post('/:id/chat-import', authenticateToken, (req, res) => {
// Get all games played in this session with timestamps
const sessionGames = db.prepare(`
SELECT sg.game_id, sg.played_at, g.title
SELECT sg.game_id, sg.played_at, g.title, g.upvotes, g.downvotes
FROM session_games sg
JOIN games g ON sg.game_id = g.id
WHERE sg.session_id = ?
@@ -282,15 +293,26 @@ router.post('/:id/chat-import', authenticateToken, (req, res) => {
}
let votesProcessed = 0;
let duplicatesSkipped = 0;
const votesByGame = {};
const sessionId = parseInt(req.params.id);
const voteMatches = []; // Debug: track which votes matched to which games
const insertChatLog = db.prepare(`
INSERT INTO chat_logs (session_id, chatter_name, message, timestamp, parsed_vote)
VALUES (?, ?, ?, ?, ?)
INSERT INTO chat_logs (session_id, chatter_name, message, timestamp, parsed_vote, message_hash)
VALUES (?, ?, ?, ?, ?, ?)
`);
const updatePopularity = db.prepare(`
UPDATE games SET popularity_score = popularity_score + ? WHERE id = ?
const checkDuplicate = db.prepare(`
SELECT id FROM chat_logs WHERE message_hash = ? LIMIT 1
`);
const updateUpvote = db.prepare(`
UPDATE games SET upvotes = upvotes + 1, popularity_score = popularity_score + 1 WHERE id = ?
`);
const updateDownvote = db.prepare(`
UPDATE games SET downvotes = downvotes + 1, popularity_score = popularity_score - 1 WHERE id = ?
`);
const processVotes = db.transaction((messages) => {
@@ -301,6 +323,16 @@ router.post('/:id/chat-import', authenticateToken, (req, res) => {
continue;
}
// Create hash for this message
const messageHash = createMessageHash(username, message, timestamp);
// Check if we've already imported this exact message
const existing = checkDuplicate.get(messageHash);
if (existing) {
duplicatesSkipped++;
continue; // Skip this duplicate message
}
// Check for vote patterns
let vote = null;
if (message.includes('thisgame++')) {
@@ -309,13 +341,14 @@ router.post('/:id/chat-import', authenticateToken, (req, res) => {
vote = 'thisgame--';
}
// Insert into chat logs
// Insert into chat logs with hash
insertChatLog.run(
req.params.id,
sessionId,
username,
message,
timestamp,
vote
vote,
messageHash
);
if (vote) {
@@ -345,8 +378,24 @@ router.post('/:id/chat-import', authenticateToken, (req, res) => {
}
if (matchedGame) {
const points = vote === 'thisgame++' ? 1 : -1;
updatePopularity.run(points, matchedGame.game_id);
const isUpvote = vote === 'thisgame++';
// Debug: track this vote match
voteMatches.push({
username: username,
vote: vote,
timestamp: timestamp,
timestamp_ms: messageTime,
matched_game: matchedGame.title,
game_played_at: matchedGame.played_at,
game_played_at_ms: new Date(matchedGame.played_at).getTime()
});
if (isUpvote) {
updateUpvote.run(matchedGame.game_id);
} else {
updateDownvote.run(matchedGame.game_id);
}
if (!votesByGame[matchedGame.game_id]) {
votesByGame[matchedGame.game_id] = {
@@ -356,7 +405,7 @@ router.post('/:id/chat-import', authenticateToken, (req, res) => {
};
}
if (points > 0) {
if (isUpvote) {
votesByGame[matchedGame.game_id].upvotes++;
} else {
votesByGame[matchedGame.game_id].downvotes++;
@@ -373,8 +422,17 @@ router.post('/:id/chat-import', authenticateToken, (req, res) => {
res.json({
message: 'Chat log imported and processed successfully',
messagesImported: chatData.length,
duplicatesSkipped,
votesProcessed,
votesByGame
votesByGame,
debug: {
sessionGamesTimeline: sessionGames.map(sg => ({
title: sg.title,
played_at: sg.played_at,
played_at_ms: new Date(sg.played_at).getTime()
})),
voteMatches: voteMatches
}
});
} catch (error) {
res.status(500).json({ error: error.message });

View File

@@ -14,14 +14,14 @@ router.get('/', (req, res) => {
activeSessions: db.prepare('SELECT COUNT(*) as count FROM sessions WHERE is_active = 1').get(),
totalGamesPlayed: db.prepare('SELECT COUNT(*) as count FROM session_games').get(),
mostPlayedGames: db.prepare(`
SELECT g.id, g.title, g.pack_name, g.play_count, g.popularity_score
SELECT g.id, g.title, g.pack_name, g.play_count, g.popularity_score, g.upvotes, g.downvotes
FROM games g
WHERE g.play_count > 0
ORDER BY g.play_count DESC
LIMIT 10
`).all(),
topRatedGames: db.prepare(`
SELECT g.id, g.title, g.pack_name, g.play_count, g.popularity_score
SELECT g.id, g.title, g.pack_name, g.play_count, g.popularity_score, g.upvotes, g.downvotes
FROM games g
WHERE g.popularity_score > 0
ORDER BY g.popularity_score DESC

View File

@@ -30,7 +30,7 @@ function App() {
<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>
<div className="sm:hidden text-sm font-bold">Jackbox Game Picker</div>
</Link>
{/* Desktop Navigation Links */}
@@ -167,11 +167,11 @@ function App() {
{/* 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>
<div className="flex flex-col md:flex-row justify-between items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<div className="text-center md:text-left">
{branding.app.name} v{branding.app.version}
</div>
<div>
<div className="text-center md:text-right">
{branding.app.description}
</div>
</div>

View File

@@ -0,0 +1,81 @@
import React from 'react';
/**
* PopularityBadge - Display popularity score with upvotes, downvotes, and ratio
*
* @param {number} upvotes - Number of upvotes
* @param {number} downvotes - Number of downvotes
* @param {number} popularityScore - Net score (upvotes - downvotes)
* @param {string} size - Size variant: 'sm', 'md', or 'lg' (default: 'sm')
* @param {boolean} showRatio - Show the upvote/downvote ratio (default: false)
* @param {boolean} showNet - Show the net score (default: true)
* @param {boolean} showCounts - Show individual counts (default: true)
*/
function PopularityBadge({
upvotes = 0,
downvotes = 0,
popularityScore = 0,
size = 'sm',
showRatio = false,
showNet = true,
showCounts = true
}) {
// Don't show badge if no votes
if (upvotes === 0 && downvotes === 0) {
return null;
}
const total = upvotes + downvotes;
const ratio = total > 0 ? ((upvotes / total) * 100).toFixed(0) : 0;
// Determine color based on net score
const isPositive = popularityScore > 0;
const isNeutral = popularityScore === 0;
const colorClasses = isNeutral
? 'bg-gray-100 dark:bg-gray-700/50 text-gray-700 dark:text-gray-300'
: isPositive
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300'
: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300';
const sizeClasses = {
sm: 'text-xs px-2 py-1',
md: 'text-sm px-3 py-1.5',
lg: 'text-base px-4 py-2'
}[size];
return (
<span
className={`inline-flex items-center gap-1 rounded font-semibold ${colorClasses} ${sizeClasses}`}
title="Cumulative popularity across all sessions"
>
{showCounts && (
<>
<span className="whitespace-nowrap">👍 {upvotes}</span>
<span className="text-gray-400 dark:text-gray-500">|</span>
<span className="whitespace-nowrap">👎 {downvotes}</span>
</>
)}
{showNet && showCounts && (
<span className="text-gray-400 dark:text-gray-500">|</span>
)}
{showNet && (
<span className="whitespace-nowrap">
{popularityScore > 0 ? '+' : ''}{popularityScore}
</span>
)}
{showRatio && (
<>
<span className="text-gray-400 dark:text-gray-500">|</span>
<span className="whitespace-nowrap">{ratio}%</span>
</>
)}
</span>
);
}
export default PopularityBadge;

View File

@@ -2,7 +2,7 @@ export const branding = {
app: {
name: 'HSO Jackbox Game Picker',
shortName: 'HSO JGP',
version: '0.2.1',
version: '0.3.0',
description: 'Spicing up Hyper Spaceout game nights!',
},
meta: {

View File

@@ -3,6 +3,7 @@ import { useAuth } from '../context/AuthContext';
import { useToast } from '../components/Toast';
import api from '../api/axios';
import { formatLocalDateTime, formatLocalDate, formatLocalTime } from '../utils/dateUtils';
import PopularityBadge from '../components/PopularityBadge';
function History() {
const { isAuthenticated } = useAuth();
@@ -297,12 +298,12 @@ function History() {
Games Played ({sessionGames.length})
</h3>
<div className="space-y-3">
{sessionGames.map((game, index) => (
{[...sessionGames].reverse().map((game, index) => (
<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>
<div className="font-semibold text-lg text-gray-800 dark:text-gray-100">
{index + 1}. {game.title}
{sessionGames.length - index}. {game.title}
</div>
<div className="text-gray-600 dark:text-gray-400">{game.pack_name}</div>
</div>
@@ -325,11 +326,22 @@ function History() {
<div>
<span className="font-semibold">Type:</span> {game.game_type || 'N/A'}
</div>
<div>
<span className="font-semibold">Popularity:</span>{' '}
<span className={game.popularity_score >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
{game.popularity_score > 0 ? '+' : ''}{game.popularity_score}
<div className="flex items-center gap-2">
<span
className="font-semibold"
title="Popularity is cumulative across all sessions where this game was played"
>
Popularity:
</span>
<PopularityBadge
upvotes={game.upvotes || 0}
downvotes={game.downvotes || 0}
popularityScore={game.popularity_score || 0}
size="sm"
showCounts={true}
showNet={true}
showRatio={true}
/>
</div>
</div>
</div>
@@ -467,9 +479,22 @@ function ChatImportPanel({ sessionId, onClose, onImportComplete }) {
const [result, setResult] = useState(null);
const { error, success } = useToast();
const handleFileUpload = async (event) => {
const file = event.target.files[0];
if (!file) return;
try {
const text = await file.text();
setChatData(text);
success('File loaded successfully');
} catch (err) {
error('Failed to read file: ' + err.message);
}
};
const handleImport = async () => {
if (!chatData.trim()) {
error('Please enter chat data');
error('Please enter chat data or upload a file');
return;
}
@@ -498,13 +523,41 @@ function ChatImportPanel({ sessionId, onClose, onImportComplete }) {
<h3 className="text-xl font-semibold mb-4 dark:text-gray-100">Import Chat Log</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Paste JSON array with format: [{"{"}"username": "...", "message": "...", "timestamp": "..."{"}"}]
Upload a JSON file or paste JSON array with format: [{"{"}"username": "...", "message": "...", "timestamp": "..."{"}"}]
<br />
The system will detect "thisgame++" and "thisgame--" patterns and update game popularity.
<br />
<span className="text-xs italic">
Note: Popularity is cumulative - votes are added to each game's all-time totals.
</span>
</p>
{/* File Upload */}
<div className="mb-4">
<label className="block text-gray-700 dark:text-gray-300 font-semibold mb-2">Chat JSON Data</label>
<label className="block text-gray-700 dark:text-gray-300 font-semibold mb-2">Upload JSON File</label>
<input
type="file"
accept=".json"
onChange={handleFileUpload}
disabled={importing}
className="block w-full text-sm text-gray-900 dark:text-gray-100
file:mr-4 file:py-2 file:px-4
file:rounded-lg file:border-0
file:text-sm file:font-semibold
file:bg-indigo-50 file:text-indigo-700
dark:file:bg-indigo-900/30 dark:file:text-indigo-300
hover:file:bg-indigo-100 dark:hover:file:bg-indigo-900/50
file:cursor-pointer cursor-pointer
disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
<div className="mb-4 text-center text-gray-500 dark:text-gray-400 text-sm">
or
</div>
<div className="mb-4">
<label className="block text-gray-700 dark:text-gray-300 font-semibold mb-2">Paste Chat JSON Data</label>
<textarea
value={chatData}
onChange={(e) => setChatData(e.target.value)}
@@ -519,6 +572,11 @@ function ChatImportPanel({ sessionId, onClose, onImportComplete }) {
<p className="font-semibold text-green-800 dark:text-green-200">Import Successful!</p>
<p className="text-sm text-green-700 dark:text-green-300">
Imported {result.messagesImported} messages, processed {result.votesProcessed} votes
{result.duplicatesSkipped > 0 && (
<span className="block mt-1 text-xs italic opacity-75">
({result.duplicatesSkipped} duplicate{result.duplicatesSkipped !== 1 ? 's' : ''} skipped)
</span>
)}
</p>
{result.votesByGame && Object.keys(result.votesByGame).length > 0 && (
<div className="mt-2 text-sm text-green-700 dark:text-green-300">
@@ -530,8 +588,47 @@ function ChatImportPanel({ sessionId, onClose, onImportComplete }) {
</li>
))}
</ul>
<p className="text-xs mt-2 italic opacity-80">
Note: Popularity is cumulative across all sessions. If a game is played multiple times, votes apply to the game itself.
</p>
</div>
)}
{/* Debug Info */}
{result.debug && (
<details className="mt-4">
<summary className="cursor-pointer font-semibold text-green-800 dark:text-green-200">
Debug Info (click to expand)
</summary>
<div className="mt-2 text-xs text-green-700 dark:text-green-300 space-y-2">
{/* Session Timeline */}
<div>
<p className="font-semibold">Session Timeline:</p>
<ul className="list-disc list-inside ml-2">
{result.debug.sessionGamesTimeline?.map((game, i) => (
<li key={i}>
{game.title} - {new Date(game.played_at).toLocaleString()}
</li>
))}
</ul>
</div>
{/* Vote Matches */}
{result.debug.voteMatches && result.debug.voteMatches.length > 0 && (
<div>
<p className="font-semibold">Vote Matches ({result.debug.voteMatches.length}):</p>
<ul className="list-disc list-inside ml-2 max-h-48 overflow-y-auto">
{result.debug.voteMatches.map((match, i) => (
<li key={i}>
{match.username}: {match.vote} at {new Date(match.timestamp).toLocaleString()} matched to "{match.matched_game}" (played at {new Date(match.game_played_at).toLocaleString()})
</li>
))}
</ul>
</div>
)}
</div>
</details>
)}
</div>
)}

View File

@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../api/axios';
import { formatLocalDateTime, formatLocalTime } from '../utils/dateUtils';
import PopularityBadge from '../components/PopularityBadge';
function Home() {
const { isAuthenticated } = useAuth();
@@ -130,6 +131,15 @@ function Home() {
Skipped
</span>
)}
<PopularityBadge
upvotes={game.upvotes || 0}
downvotes={game.downvotes || 0}
popularityScore={game.popularity_score || 0}
size="sm"
showCounts={true}
showNet={true}
showRatio={true}
/>
</div>
<div className="text-xs sm:text-sm text-gray-500 dark:text-gray-400 mt-1">
({game.pack_name})

View File

@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useToast } from '../components/Toast';
import api from '../api/axios';
import PopularityBadge from '../components/PopularityBadge';
function Manager() {
const { isAuthenticated, loading: authLoading } = useAuth();
@@ -427,12 +428,14 @@ function PackTreeNode({ pack, isExpanded, onToggle, onTogglePack, onToggleGame,
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
{/* Pack Header */}
<div
className={`flex items-center gap-2 p-3 transition ${getPackBackground()}`}
className={`p-3 transition ${getPackBackground()}`}
>
{/* Row 1: Expand/Collapse + Checkbox + Pack Name */}
<div className="flex items-center gap-2 mb-2">
{/* Expand/Collapse Button */}
<button
onClick={onToggle}
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 w-6 h-6 flex items-center justify-center"
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 w-6 h-6 flex items-center justify-center flex-shrink-0"
>
{isExpanded ? '▼' : '▶'}
</button>
@@ -445,37 +448,42 @@ function PackTreeNode({ pack, isExpanded, onToggle, onTogglePack, onToggleGame,
e.stopPropagation();
onTogglePack(pack.name, e.target.checked);
}}
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700 cursor-pointer"
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700 cursor-pointer flex-shrink-0 mr-2"
title={`${isFullyEnabled ? 'Disable' : 'Enable'} all games in ${pack.name}`}
/>
{/* Pack Name and Info */}
{/* Pack Name */}
<div
className="flex-1 flex items-center justify-between cursor-pointer"
className="flex-1 min-w-0 cursor-pointer"
onClick={onToggle}
>
<span className={`font-semibold ${
<div className={`font-semibold text-base md:text-lg truncate ${
isFullyDisabled
? 'text-gray-500 dark:text-gray-500'
: 'text-gray-800 dark:text-gray-100'
}`}>
📦 {pack.name}
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">
{pack.enabled_count} / {pack.total_count} enabled
</span>
</div>
</div>
</div>
{/* Pack Favor Controls - Only show if pack has enabled games */}
{/* Row 2: Metadata + Favor Controls */}
<div className="flex items-center justify-between gap-2 ml-12">
{/* Pack Info */}
<div className="text-xs md:text-sm text-gray-500 dark:text-gray-400">
{pack.enabled_count} / {pack.total_count} enabled
</div>
{/* Pack Favor Controls */}
{!isFullyDisabled && (
<div className="flex items-center gap-1 ml-2" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-1.5 md:gap-2" onClick={(e) => e.stopPropagation()}>
{pack.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-1 rounded">
<span className="text-xs md:text-sm font-semibold bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-0.5 md:py-1 rounded whitespace-nowrap">
Favored
</span>
)}
{pack.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-1 rounded">
<span className="text-xs md:text-sm font-semibold bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 px-2 py-0.5 md:py-1 rounded whitespace-nowrap">
Disfavored
</span>
)}
@@ -488,7 +496,7 @@ function PackTreeNode({ pack, isExpanded, onToggle, onTogglePack, onToggleGame,
onPackFavorChange(pack.name, 1);
}
}}
className={`text-xs px-2 py-1 rounded transition font-medium ${
className={`text-xs md:text-sm px-2 md:px-3 py-1 md:py-1.5 rounded transition font-medium whitespace-nowrap ${
pack.favor_bias === 1
? 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
: 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800'
@@ -505,7 +513,7 @@ function PackTreeNode({ pack, isExpanded, onToggle, onTogglePack, onToggleGame,
onPackFavorChange(pack.name, -1);
}
}}
className={`text-xs px-2 py-1 rounded transition font-medium ${
className={`text-xs md:text-sm px-2 md:px-3 py-1 md:py-1.5 rounded transition font-medium whitespace-nowrap ${
pack.favor_bias === -1
? 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
: 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 hover:bg-red-200 dark:hover:bg-red-800'
@@ -516,6 +524,7 @@ function PackTreeNode({ pack, isExpanded, onToggle, onTogglePack, onToggleGame,
</div>
)}
</div>
</div>
{/* Games List */}
{isExpanded && pack.games.length > 0 && (
@@ -535,8 +544,10 @@ function PackTreeNode({ pack, isExpanded, onToggle, onTogglePack, onToggleGame,
return (
<div
key={game.id}
className={`flex items-center gap-2 p-3 pl-12 border-t border-gray-100 dark:border-gray-700 transition ${getGameBackground()}`}
className={`p-3 pl-8 sm:pl-12 border-t border-gray-100 dark:border-gray-700 transition ${getGameBackground()}`}
>
{/* Row 1: Checkbox + Game Icon + Title */}
<div className="flex items-center gap-2 mb-2">
{/* Game Checkbox */}
<input
type="checkbox"
@@ -545,13 +556,12 @@ function PackTreeNode({ pack, isExpanded, onToggle, onTogglePack, onToggleGame,
e.stopPropagation();
onToggleGame(game.id);
}}
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700 cursor-pointer"
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700 cursor-pointer flex-shrink-0 mr-3"
/>
{/* Game Icon and Title */}
<div className="flex-1 flex items-center gap-2 min-w-0">
<span className="text-lg">🎮</span>
<span className={`font-medium truncate ${
<span className="text-lg md:text-xl flex-shrink-0">🎮</span>
<span className={`font-medium text-base md:text-lg truncate flex-1 ${
game.enabled
? 'text-gray-800 dark:text-gray-200'
: 'text-gray-500 dark:text-gray-500 line-through'
@@ -560,35 +570,37 @@ function PackTreeNode({ pack, isExpanded, onToggle, onTogglePack, onToggleGame,
</span>
</div>
{/* Row 2: Game Stats + Favor Controls */}
<div className="flex items-center justify-between gap-2 ml-7">
{/* Game Stats */}
<div className="hidden sm:flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-3 text-xs md:text-sm text-gray-500 dark:text-gray-400">
<span title="Players">{game.min_players}-{game.max_players} 👥</span>
<span title="Play count">{game.play_count} </span>
<span
title="Popularity"
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}
</span>
<PopularityBadge
upvotes={game.upvotes || 0}
downvotes={game.downvotes || 0}
popularityScore={game.popularity_score || 0}
size="sm"
showCounts={false}
showNet={true}
showRatio={false}
/>
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-wrap">
{/* Game Favor Status Badge and Controls - Only show if game is enabled */}
{/* Favor Controls */}
{game.enabled && (
<>
<div className="flex items-center gap-1.5 md:gap-2">
{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">
<span className="text-xs md:text-sm font-semibold bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-0.5 md:py-1 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">
<span className="text-xs md:text-sm font-semibold bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 px-2 py-0.5 md:py-1 rounded whitespace-nowrap">
Disfavored
</span>
)}
{/* Game Favor Controls */}
<button
onClick={(e) => {
e.stopPropagation();
@@ -598,7 +610,7 @@ function PackTreeNode({ pack, isExpanded, onToggle, onTogglePack, onToggleGame,
onGameFavorChange(game.id, 1);
}
}}
className={`text-xs px-2 py-1 rounded transition font-medium ${
className={`text-xs md:text-sm px-2 md:px-3 py-1 md:py-1.5 rounded transition font-medium whitespace-nowrap ${
game.favor_bias === 1
? 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
: 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800'
@@ -615,7 +627,7 @@ function PackTreeNode({ pack, isExpanded, onToggle, onTogglePack, onToggleGame,
onGameFavorChange(game.id, -1);
}
}}
className={`text-xs px-2 py-1 rounded transition font-medium ${
className={`text-xs md:text-sm px-2 md:px-3 py-1 md:py-1.5 rounded transition font-medium whitespace-nowrap ${
game.favor_bias === -1
? 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
: 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 hover:bg-red-200 dark:hover:bg-red-800'
@@ -623,27 +635,8 @@ function PackTreeNode({ pack, isExpanded, onToggle, onTogglePack, onToggleGame,
>
{game.favor_bias === -1 ? 'Undisfavor' : 'Disfavor'}
</button>
</>
</div>
)}
<button
onClick={() => onEditGame(game)}
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-sm px-2 py-1"
title="Edit game"
>
</button>
<button
onClick={() => onDeleteClick(game.id)}
className={`text-sm px-2 py-1 transition ${
confirmingDelete === game.id
? 'text-red-800 dark:text-red-200 animate-pulse'
: 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300'
}`}
title={confirmingDelete === game.id ? 'Click again to confirm' : 'Delete game'}
>
🗑{confirmingDelete === game.id ? '?' : ''}
</button>
</div>
</div>
);

View File

@@ -4,6 +4,7 @@ import { useAuth } from '../context/AuthContext';
import api from '../api/axios';
import GamePoolModal from '../components/GamePoolModal';
import { formatLocalTime } from '../utils/dateUtils';
import PopularityBadge from '../components/PopularityBadge';
function Picker() {
const { isAuthenticated, loading: authLoading } = useAuth();
@@ -15,6 +16,7 @@ function Picker() {
const [loading, setLoading] = useState(true);
const [picking, setPicking] = useState(false);
const [error, setError] = useState('');
const [showPopularity, setShowPopularity] = useState(true);
// Filters
const [playerCount, setPlayerCount] = useState('');
@@ -542,12 +544,22 @@ function Picker() {
<span className="font-semibold text-gray-700 dark:text-gray-300">Play Count:</span>
<span className="ml-2 text-gray-600 dark:text-gray-400">{selectedGame.play_count}</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Popularity:</span>
<span className="ml-2 text-gray-600 dark:text-gray-400">
{selectedGame.popularity_score > 0 ? '+' : ''}
{selectedGame.popularity_score}
<div className="flex items-center gap-2">
<span
className="font-semibold text-gray-700 dark:text-gray-300"
title="Cumulative popularity across all sessions"
>
Popularity:
</span>
<PopularityBadge
upvotes={selectedGame.upvotes || 0}
downvotes={selectedGame.downvotes || 0}
popularityScore={selectedGame.popularity_score || 0}
size="md"
showCounts={true}
showNet={true}
showRatio={true}
/>
</div>
</div>
@@ -645,6 +657,7 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
const [games, setGames] = useState([]);
const [loading, setLoading] = useState(true);
const [confirmingRemove, setConfirmingRemove] = useState(null);
const [showPopularity, setShowPopularity] = useState(true);
useEffect(() => {
loadGames();
@@ -714,9 +727,20 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-6">
<h3 className="text-lg sm:text-xl font-semibold mb-4 text-gray-800 dark:text-gray-100">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg sm:text-xl font-semibold text-gray-800 dark:text-gray-100">
Games Played This Session ({games.length})
</h3>
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
<input
type="checkbox"
checked={showPopularity}
onChange={(e) => setShowPopularity(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700 cursor-pointer"
/>
<span className="whitespace-nowrap">Show Popularity</span>
</label>
</div>
{loading ? (
<p className="text-gray-500 dark:text-gray-400">Loading...</p>
) : games.length === 0 ? (
@@ -752,6 +776,17 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
Manual
</span>
)}
{showPopularity && (
<PopularityBadge
upvotes={game.upvotes || 0}
downvotes={game.downvotes || 0}
popularityScore={game.popularity_score || 0}
size="sm"
showCounts={true}
showNet={true}
showRatio={false}
/>
)}
</div>
<div className="text-xs sm:text-sm text-gray-500 dark:text-gray-400 mt-1">
{game.pack_name} {formatLocalTime(game.played_at)}