pretty much ready to 'ship'
This commit is contained in:
@@ -84,6 +84,46 @@ function initializeDatabase() {
|
|||||||
// Column already exists, ignore error
|
// 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
|
// Packs table for pack-level favoriting
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS packs (
|
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');
|
console.log('Database initialized successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const crypto = require('crypto');
|
||||||
const { authenticateToken } = require('../middleware/auth');
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
const db = require('../database');
|
const db = require('../database');
|
||||||
|
|
||||||
const router = express.Router();
|
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
|
// Get all sessions
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -175,7 +184,9 @@ router.get('/:id/games', (req, res) => {
|
|||||||
g.game_type,
|
g.game_type,
|
||||||
g.min_players,
|
g.min_players,
|
||||||
g.max_players,
|
g.max_players,
|
||||||
g.popularity_score
|
g.popularity_score,
|
||||||
|
g.upvotes,
|
||||||
|
g.downvotes
|
||||||
FROM session_games sg
|
FROM session_games sg
|
||||||
JOIN games g ON sg.game_id = g.id
|
JOIN games g ON sg.game_id = g.id
|
||||||
WHERE sg.session_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
|
// Get all games played in this session with timestamps
|
||||||
const sessionGames = db.prepare(`
|
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
|
FROM session_games sg
|
||||||
JOIN games g ON sg.game_id = g.id
|
JOIN games g ON sg.game_id = g.id
|
||||||
WHERE sg.session_id = ?
|
WHERE sg.session_id = ?
|
||||||
@@ -282,15 +293,26 @@ router.post('/:id/chat-import', authenticateToken, (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let votesProcessed = 0;
|
let votesProcessed = 0;
|
||||||
|
let duplicatesSkipped = 0;
|
||||||
const votesByGame = {};
|
const votesByGame = {};
|
||||||
|
const sessionId = parseInt(req.params.id);
|
||||||
|
const voteMatches = []; // Debug: track which votes matched to which games
|
||||||
|
|
||||||
const insertChatLog = db.prepare(`
|
const insertChatLog = db.prepare(`
|
||||||
INSERT INTO chat_logs (session_id, chatter_name, message, timestamp, parsed_vote)
|
INSERT INTO chat_logs (session_id, chatter_name, message, timestamp, parsed_vote, message_hash)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const updatePopularity = db.prepare(`
|
const checkDuplicate = db.prepare(`
|
||||||
UPDATE games SET popularity_score = popularity_score + ? WHERE id = ?
|
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) => {
|
const processVotes = db.transaction((messages) => {
|
||||||
@@ -301,6 +323,16 @@ router.post('/:id/chat-import', authenticateToken, (req, res) => {
|
|||||||
continue;
|
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
|
// Check for vote patterns
|
||||||
let vote = null;
|
let vote = null;
|
||||||
if (message.includes('thisgame++')) {
|
if (message.includes('thisgame++')) {
|
||||||
@@ -309,13 +341,14 @@ router.post('/:id/chat-import', authenticateToken, (req, res) => {
|
|||||||
vote = 'thisgame--';
|
vote = 'thisgame--';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert into chat logs
|
// Insert into chat logs with hash
|
||||||
insertChatLog.run(
|
insertChatLog.run(
|
||||||
req.params.id,
|
sessionId,
|
||||||
username,
|
username,
|
||||||
message,
|
message,
|
||||||
timestamp,
|
timestamp,
|
||||||
vote
|
vote,
|
||||||
|
messageHash
|
||||||
);
|
);
|
||||||
|
|
||||||
if (vote) {
|
if (vote) {
|
||||||
@@ -345,8 +378,24 @@ router.post('/:id/chat-import', authenticateToken, (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (matchedGame) {
|
if (matchedGame) {
|
||||||
const points = vote === 'thisgame++' ? 1 : -1;
|
const isUpvote = vote === 'thisgame++';
|
||||||
updatePopularity.run(points, matchedGame.game_id);
|
|
||||||
|
// 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]) {
|
if (!votesByGame[matchedGame.game_id]) {
|
||||||
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++;
|
votesByGame[matchedGame.game_id].upvotes++;
|
||||||
} else {
|
} else {
|
||||||
votesByGame[matchedGame.game_id].downvotes++;
|
votesByGame[matchedGame.game_id].downvotes++;
|
||||||
@@ -373,8 +422,17 @@ router.post('/:id/chat-import', authenticateToken, (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
message: 'Chat log imported and processed successfully',
|
message: 'Chat log imported and processed successfully',
|
||||||
messagesImported: chatData.length,
|
messagesImported: chatData.length,
|
||||||
|
duplicatesSkipped,
|
||||||
votesProcessed,
|
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) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ router.get('/', (req, res) => {
|
|||||||
activeSessions: db.prepare('SELECT COUNT(*) as count FROM sessions WHERE is_active = 1').get(),
|
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(),
|
totalGamesPlayed: db.prepare('SELECT COUNT(*) as count FROM session_games').get(),
|
||||||
mostPlayedGames: db.prepare(`
|
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
|
FROM games g
|
||||||
WHERE g.play_count > 0
|
WHERE g.play_count > 0
|
||||||
ORDER BY g.play_count DESC
|
ORDER BY g.play_count DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`).all(),
|
`).all(),
|
||||||
topRatedGames: db.prepare(`
|
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
|
FROM games g
|
||||||
WHERE g.popularity_score > 0
|
WHERE g.popularity_score > 0
|
||||||
ORDER BY g.popularity_score DESC
|
ORDER BY g.popularity_score DESC
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ function App() {
|
|||||||
<div className="hidden sm:block">
|
<div className="hidden sm:block">
|
||||||
<div className="text-lg sm:text-xl font-bold">{branding.app.name}</div>
|
<div className="text-lg sm:text-xl font-bold">{branding.app.name}</div>
|
||||||
</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>
|
</Link>
|
||||||
|
|
||||||
{/* Desktop Navigation Links */}
|
{/* Desktop Navigation Links */}
|
||||||
@@ -167,11 +167,11 @@ function App() {
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-12 flex-shrink-0">
|
<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="container mx-auto px-4 py-6">
|
||||||
<div className="flex justify-between items-center text-sm text-gray-600 dark:text-gray-400">
|
<div className="flex flex-col md:flex-row justify-between items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<div>
|
<div className="text-center md:text-left">
|
||||||
{branding.app.name} v{branding.app.version}
|
{branding.app.name} v{branding.app.version}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="text-center md:text-right">
|
||||||
{branding.app.description}
|
{branding.app.description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
81
frontend/src/components/PopularityBadge.jsx
Normal file
81
frontend/src/components/PopularityBadge.jsx
Normal 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;
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@ export const branding = {
|
|||||||
app: {
|
app: {
|
||||||
name: 'HSO Jackbox Game Picker',
|
name: 'HSO Jackbox Game Picker',
|
||||||
shortName: 'HSO JGP',
|
shortName: 'HSO JGP',
|
||||||
version: '0.2.1',
|
version: '0.3.0',
|
||||||
description: 'Spicing up Hyper Spaceout game nights!',
|
description: 'Spicing up Hyper Spaceout game nights!',
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useAuth } from '../context/AuthContext';
|
|||||||
import { useToast } from '../components/Toast';
|
import { useToast } from '../components/Toast';
|
||||||
import api from '../api/axios';
|
import api from '../api/axios';
|
||||||
import { formatLocalDateTime, formatLocalDate, formatLocalTime } from '../utils/dateUtils';
|
import { formatLocalDateTime, formatLocalDate, formatLocalTime } from '../utils/dateUtils';
|
||||||
|
import PopularityBadge from '../components/PopularityBadge';
|
||||||
|
|
||||||
function History() {
|
function History() {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
@@ -297,12 +298,12 @@ function History() {
|
|||||||
Games Played ({sessionGames.length})
|
Games Played ({sessionGames.length})
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{sessionGames.map((game, index) => (
|
{[...sessionGames].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 key={game.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-gray-700/50">
|
||||||
<div className="flex justify-between items-start mb-2">
|
<div className="flex justify-between items-start mb-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-lg text-gray-800 dark:text-gray-100">
|
<div className="font-semibold text-lg text-gray-800 dark:text-gray-100">
|
||||||
{index + 1}. {game.title}
|
{sessionGames.length - index}. {game.title}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-gray-600 dark:text-gray-400">{game.pack_name}</div>
|
<div className="text-gray-600 dark:text-gray-400">{game.pack_name}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -325,11 +326,22 @@ function History() {
|
|||||||
<div>
|
<div>
|
||||||
<span className="font-semibold">Type:</span> {game.game_type || 'N/A'}
|
<span className="font-semibold">Type:</span> {game.game_type || 'N/A'}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-semibold">Popularity:</span>{' '}
|
<span
|
||||||
<span className={game.popularity_score >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
|
className="font-semibold"
|
||||||
{game.popularity_score > 0 ? '+' : ''}{game.popularity_score}
|
title="Popularity is cumulative across all sessions where this game was played"
|
||||||
|
>
|
||||||
|
Popularity:
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -467,9 +479,22 @@ function ChatImportPanel({ sessionId, onClose, onImportComplete }) {
|
|||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const { error, success } = useToast();
|
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 () => {
|
const handleImport = async () => {
|
||||||
if (!chatData.trim()) {
|
if (!chatData.trim()) {
|
||||||
error('Please enter chat data');
|
error('Please enter chat data or upload a file');
|
||||||
return;
|
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>
|
<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">
|
<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 />
|
<br />
|
||||||
The system will detect "thisgame++" and "thisgame--" patterns and update game popularity.
|
The system will detect "thisgame++" and "thisgame--" patterns and update game popularity.
|
||||||
|
<br />
|
||||||
|
<span className="text-xs italic">
|
||||||
|
Note: Popularity is cumulative - votes are added to each game's all-time totals.
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* File Upload */}
|
||||||
<div className="mb-4">
|
<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
|
<textarea
|
||||||
value={chatData}
|
value={chatData}
|
||||||
onChange={(e) => setChatData(e.target.value)}
|
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="font-semibold text-green-800 dark:text-green-200">Import Successful!</p>
|
||||||
<p className="text-sm text-green-700 dark:text-green-300">
|
<p className="text-sm text-green-700 dark:text-green-300">
|
||||||
Imported {result.messagesImported} messages, processed {result.votesProcessed} votes
|
Imported {result.messagesImported} messages, processed {result.votesProcessed} votes
|
||||||
|
{result.duplicatesSkipped > 0 && (
|
||||||
|
<span className="block mt-1 text-xs italic opacity-75">
|
||||||
|
({result.duplicatesSkipped} duplicate{result.duplicatesSkipped !== 1 ? 's' : ''} skipped)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
{result.votesByGame && Object.keys(result.votesByGame).length > 0 && (
|
{result.votesByGame && Object.keys(result.votesByGame).length > 0 && (
|
||||||
<div className="mt-2 text-sm text-green-700 dark:text-green-300">
|
<div className="mt-2 text-sm text-green-700 dark:text-green-300">
|
||||||
@@ -530,8 +588,47 @@ function ChatImportPanel({ sessionId, onClose, onImportComplete }) {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
|||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import api from '../api/axios';
|
import api from '../api/axios';
|
||||||
import { formatLocalDateTime, formatLocalTime } from '../utils/dateUtils';
|
import { formatLocalDateTime, formatLocalTime } from '../utils/dateUtils';
|
||||||
|
import PopularityBadge from '../components/PopularityBadge';
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
@@ -130,6 +131,15 @@ function Home() {
|
|||||||
⏭️ Skipped
|
⏭️ Skipped
|
||||||
</span>
|
</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 className="text-xs sm:text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<div className="text-xs sm:text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
({game.pack_name})
|
({game.pack_name})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useToast } from '../components/Toast';
|
import { useToast } from '../components/Toast';
|
||||||
import api from '../api/axios';
|
import api from '../api/axios';
|
||||||
|
import PopularityBadge from '../components/PopularityBadge';
|
||||||
|
|
||||||
function Manager() {
|
function Manager() {
|
||||||
const { isAuthenticated, loading: authLoading } = useAuth();
|
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">
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
{/* Pack Header */}
|
{/* Pack Header */}
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 p-3 transition ${getPackBackground()}`}
|
className={`p-3 transition ${getPackBackground()}`}
|
||||||
>
|
>
|
||||||
{/* Expand/Collapse Button */}
|
{/* Row 1: Expand/Collapse + Checkbox + Pack Name */}
|
||||||
<button
|
<div className="flex items-center gap-2 mb-2">
|
||||||
onClick={onToggle}
|
{/* Expand/Collapse Button */}
|
||||||
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"
|
<button
|
||||||
>
|
onClick={onToggle}
|
||||||
{isExpanded ? '▼' : '▶'}
|
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"
|
||||||
</button>
|
>
|
||||||
|
{isExpanded ? '▼' : '▶'}
|
||||||
{/* Pack Checkbox */}
|
</button>
|
||||||
<input
|
|
||||||
type="checkbox"
|
{/* Pack Checkbox */}
|
||||||
checked={isFullyEnabled}
|
<input
|
||||||
onChange={(e) => {
|
type="checkbox"
|
||||||
e.stopPropagation();
|
checked={isFullyEnabled}
|
||||||
onTogglePack(pack.name, e.target.checked);
|
onChange={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
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"
|
onTogglePack(pack.name, e.target.checked);
|
||||||
title={`${isFullyEnabled ? 'Disable' : 'Enable'} all games in ${pack.name}`}
|
}}
|
||||||
/>
|
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 */}
|
/>
|
||||||
<div
|
|
||||||
className="flex-1 flex items-center justify-between cursor-pointer"
|
{/* Pack Name */}
|
||||||
onClick={onToggle}
|
<div
|
||||||
>
|
className="flex-1 min-w-0 cursor-pointer"
|
||||||
<span className={`font-semibold ${
|
onClick={onToggle}
|
||||||
isFullyDisabled
|
>
|
||||||
? 'text-gray-500 dark:text-gray-500'
|
<div className={`font-semibold text-base md:text-lg truncate ${
|
||||||
: 'text-gray-800 dark:text-gray-100'
|
isFullyDisabled
|
||||||
}`}>
|
? 'text-gray-500 dark:text-gray-500'
|
||||||
📦 {pack.name}
|
: 'text-gray-800 dark:text-gray-100'
|
||||||
</span>
|
}`}>
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
📦 {pack.name}
|
||||||
{pack.enabled_count} / {pack.total_count} enabled
|
</div>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pack Favor Controls - Only show if pack has enabled games */}
|
{/* Row 2: Metadata + Favor Controls */}
|
||||||
{!isFullyDisabled && (
|
<div className="flex items-center justify-between gap-2 ml-12">
|
||||||
<div className="flex items-center gap-1 ml-2" onClick={(e) => e.stopPropagation()}>
|
{/* Pack Info */}
|
||||||
{pack.favor_bias === 1 && (
|
<div className="text-xs md:text-sm text-gray-500 dark:text-gray-400">
|
||||||
<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">
|
{pack.enabled_count} / {pack.total_count} enabled
|
||||||
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>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Games List */}
|
{/* Games List */}
|
||||||
@@ -535,23 +544,24 @@ function PackTreeNode({ pack, isExpanded, onToggle, onTogglePack, onToggleGame,
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={game.id}
|
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 */}
|
{/* Row 1: Checkbox + Game Icon + Title */}
|
||||||
<input
|
<div className="flex items-center gap-2 mb-2">
|
||||||
type="checkbox"
|
{/* Game Checkbox */}
|
||||||
checked={game.enabled}
|
<input
|
||||||
onChange={(e) => {
|
type="checkbox"
|
||||||
e.stopPropagation();
|
checked={game.enabled}
|
||||||
onToggleGame(game.id);
|
onChange={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
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"
|
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 */}
|
/>
|
||||||
<div className="flex-1 flex items-center gap-2 min-w-0">
|
|
||||||
<span className="text-lg">🎮</span>
|
{/* Game Icon and Title */}
|
||||||
<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
|
game.enabled
|
||||||
? 'text-gray-800 dark:text-gray-200'
|
? 'text-gray-800 dark:text-gray-200'
|
||||||
: 'text-gray-500 dark:text-gray-500 line-through'
|
: 'text-gray-500 dark:text-gray-500 line-through'
|
||||||
@@ -560,35 +570,37 @@ function PackTreeNode({ pack, isExpanded, onToggle, onTogglePack, onToggleGame,
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Game Stats */}
|
{/* Row 2: Game Stats + Favor Controls */}
|
||||||
<div className="hidden sm:flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
<div className="flex items-center justify-between gap-2 ml-7">
|
||||||
<span title="Players">{game.min_players}-{game.max_players} 👥</span>
|
{/* Game Stats */}
|
||||||
<span title="Play count">{game.play_count} ▶️</span>
|
<div className="flex items-center gap-3 text-xs md:text-sm text-gray-500 dark:text-gray-400">
|
||||||
<span
|
<span title="Players">{game.min_players}-{game.max_players} 👥</span>
|
||||||
title="Popularity"
|
<span title="Play count">{game.play_count} ▶️</span>
|
||||||
className={game.popularity_score >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}
|
<PopularityBadge
|
||||||
>
|
upvotes={game.upvotes || 0}
|
||||||
{game.popularity_score > 0 ? '+' : ''}{game.popularity_score} ⭐
|
downvotes={game.downvotes || 0}
|
||||||
</span>
|
popularityScore={game.popularity_score || 0}
|
||||||
</div>
|
size="sm"
|
||||||
|
showCounts={false}
|
||||||
{/* Actions */}
|
showNet={true}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
showRatio={false}
|
||||||
{/* Game Favor Status Badge and Controls - Only show if game is enabled */}
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Favor Controls */}
|
||||||
{game.enabled && (
|
{game.enabled && (
|
||||||
<>
|
<div className="flex items-center gap-1.5 md:gap-2">
|
||||||
{game.favor_bias === 1 && (
|
{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
|
Favored
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{game.favor_bias === -1 && (
|
{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
|
Disfavored
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Game Favor Controls */}
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -598,7 +610,7 @@ function PackTreeNode({ pack, isExpanded, onToggle, onTogglePack, onToggleGame,
|
|||||||
onGameFavorChange(game.id, 1);
|
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
|
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-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'
|
: '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);
|
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
|
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-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'
|
: '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'}
|
{game.favor_bias === -1 ? 'Undisfavor' : 'Disfavor'}
|
||||||
</button>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useAuth } from '../context/AuthContext';
|
|||||||
import api from '../api/axios';
|
import api from '../api/axios';
|
||||||
import GamePoolModal from '../components/GamePoolModal';
|
import GamePoolModal from '../components/GamePoolModal';
|
||||||
import { formatLocalTime } from '../utils/dateUtils';
|
import { formatLocalTime } from '../utils/dateUtils';
|
||||||
|
import PopularityBadge from '../components/PopularityBadge';
|
||||||
|
|
||||||
function Picker() {
|
function Picker() {
|
||||||
const { isAuthenticated, loading: authLoading } = useAuth();
|
const { isAuthenticated, loading: authLoading } = useAuth();
|
||||||
@@ -15,6 +16,7 @@ function Picker() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [picking, setPicking] = useState(false);
|
const [picking, setPicking] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [showPopularity, setShowPopularity] = useState(true);
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
const [playerCount, setPlayerCount] = useState('');
|
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="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>
|
<span className="ml-2 text-gray-600 dark:text-gray-400">{selectedGame.play_count}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Popularity:</span>
|
<span
|
||||||
<span className="ml-2 text-gray-600 dark:text-gray-400">
|
className="font-semibold text-gray-700 dark:text-gray-300"
|
||||||
{selectedGame.popularity_score > 0 ? '+' : ''}
|
title="Cumulative popularity across all sessions"
|
||||||
{selectedGame.popularity_score}
|
>
|
||||||
|
Popularity:
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -645,6 +657,7 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
|
|||||||
const [games, setGames] = useState([]);
|
const [games, setGames] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [confirmingRemove, setConfirmingRemove] = useState(null);
|
const [confirmingRemove, setConfirmingRemove] = useState(null);
|
||||||
|
const [showPopularity, setShowPopularity] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadGames();
|
loadGames();
|
||||||
@@ -714,9 +727,20 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-6">
|
<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">
|
||||||
Games Played This Session ({games.length})
|
<h3 className="text-lg sm:text-xl font-semibold text-gray-800 dark:text-gray-100">
|
||||||
</h3>
|
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 ? (
|
{loading ? (
|
||||||
<p className="text-gray-500 dark:text-gray-400">Loading...</p>
|
<p className="text-gray-500 dark:text-gray-400">Loading...</p>
|
||||||
) : games.length === 0 ? (
|
) : games.length === 0 ? (
|
||||||
@@ -752,6 +776,17 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
|
|||||||
Manual
|
Manual
|
||||||
</span>
|
</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>
|
||||||
<div className="text-xs sm:text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<div className="text-xs sm:text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
{game.pack_name} • {formatLocalTime(game.played_at)}
|
{game.pack_name} • {formatLocalTime(game.played_at)}
|
||||||
|
|||||||
Reference in New Issue
Block a user