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

@@ -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,94 +428,102 @@ 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()}`}
>
{/* 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"
>
{isExpanded ? '▼' : '▶'}
</button>
{/* Pack Checkbox */}
<input
type="checkbox"
checked={isFullyEnabled}
onChange={(e) => {
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"
title={`${isFullyEnabled ? 'Disable' : 'Enable'} all games in ${pack.name}`}
/>
{/* Pack Name and Info */}
<div
className="flex-1 flex items-center justify-between cursor-pointer"
onClick={onToggle}
>
<span className={`font-semibold ${
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>
{/* 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 flex-shrink-0"
>
{isExpanded ? '▼' : '▶'}
</button>
{/* Pack Checkbox */}
<input
type="checkbox"
checked={isFullyEnabled}
onChange={(e) => {
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 flex-shrink-0 mr-2"
title={`${isFullyEnabled ? 'Disable' : 'Enable'} all games in ${pack.name}`}
/>
{/* Pack Name */}
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={onToggle}
>
<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}
</div>
</div>
</div>
{/* Pack Favor Controls - Only show if pack has enabled games */}
{!isFullyDisabled && (
<div className="flex items-center gap-1 ml-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">
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">
Disfavored
</span>
)}
<button
onClick={(e) => {
e.stopPropagation();
if (pack.favor_bias === 1) {
onPackFavorChange(pack.name, 0);
} else {
onPackFavorChange(pack.name, 1);
}
}}
className={`text-xs px-2 py-1 rounded transition font-medium ${
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'
}`}
>
{pack.favor_bias === 1 ? 'Unfavor' : 'Favor'}
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (pack.favor_bias === -1) {
onPackFavorChange(pack.name, 0);
} else {
onPackFavorChange(pack.name, -1);
}
}}
className={`text-xs px-2 py-1 rounded transition font-medium ${
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'
}`}
>
{pack.favor_bias === -1 ? 'Undisfavor' : 'Disfavor'}
</button>
{/* 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.5 md:gap-2" onClick={(e) => e.stopPropagation()}>
{pack.favor_bias === 1 && (
<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 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>
)}
<button
onClick={(e) => {
e.stopPropagation();
if (pack.favor_bias === 1) {
onPackFavorChange(pack.name, 0);
} else {
onPackFavorChange(pack.name, 1);
}
}}
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'
}`}
>
{pack.favor_bias === 1 ? 'Unfavor' : 'Favor'}
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (pack.favor_bias === -1) {
onPackFavorChange(pack.name, 0);
} else {
onPackFavorChange(pack.name, -1);
}
}}
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'
}`}
>
{pack.favor_bias === -1 ? 'Undisfavor' : 'Disfavor'}
</button>
</div>
)}
</div>
</div>
{/* Games List */}
@@ -535,23 +544,24 @@ 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()}`}
>
{/* Game Checkbox */}
<input
type="checkbox"
checked={game.enabled}
onChange={(e) => {
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"
/>
{/* 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 ${
{/* Row 1: Checkbox + Game Icon + Title */}
<div className="flex items-center gap-2 mb-2">
{/* Game Checkbox */}
<input
type="checkbox"
checked={game.enabled}
onChange={(e) => {
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 flex-shrink-0 mr-3"
/>
{/* Game Icon and Title */}
<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>
{/* Game Stats */}
<div className="hidden sm:flex items-center gap-4 text-xs 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>
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-wrap">
{/* Game Favor Status Badge and Controls - Only show if game is enabled */}
{/* Row 2: Game Stats + Favor Controls */}
<div className="flex items-center justify-between gap-2 ml-7">
{/* Game Stats */}
<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>
<PopularityBadge
upvotes={game.upvotes || 0}
downvotes={game.downvotes || 0}
popularityScore={game.popularity_score || 0}
size="sm"
showCounts={false}
showNet={true}
showRatio={false}
/>
</div>
{/* 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,42 +627,23 @@ function PackTreeNode({ pack, isExpanded, onToggle, onTogglePack, onToggleGame,
onGameFavorChange(game.id, -1);
}
}}
className={`text-xs px-2 py-1 rounded transition font-medium ${
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'
}`}
>
{game.favor_bias === -1 ? 'Undisfavor' : 'Disfavor'}
</button>
</>
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'
}`}
>
{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>
);
})}
</div>
)}
</div>
);
})}
</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">
Games Played This Session ({games.length})
</h3>
<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)}