Files
jackboxpartypack-gamepicker/frontend/src/pages/History.jsx
2025-10-30 19:27:23 -04:00

678 lines
28 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback } from 'react';
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();
const { error, success } = useToast();
const [sessions, setSessions] = useState([]);
const [selectedSession, setSelectedSession] = useState(null);
const [sessionGames, setSessionGames] = useState([]);
const [loading, setLoading] = useState(true);
const [showChatImport, setShowChatImport] = useState(false);
const [closingSession, setClosingSession] = useState(null);
const [showAllSessions, setShowAllSessions] = useState(false);
const [deletingSession, setDeletingSession] = useState(null);
const loadSessions = useCallback(async () => {
try {
const response = await api.get('/sessions');
setSessions(response.data);
} catch (err) {
console.error('Failed to load sessions', err);
} finally {
setLoading(false);
}
}, []);
const refreshSessionGames = useCallback(async (sessionId, silent = false) => {
try {
const response = await api.get(`/sessions/${sessionId}/games`);
// Reverse chronological order (most recent first) - create new array to avoid mutation
setSessionGames([...response.data].reverse());
} catch (err) {
if (!silent) {
console.error('Failed to load session games', err);
}
}
}, []);
useEffect(() => {
loadSessions();
}, [loadSessions]);
// Auto-select active session if navigating from picker
useEffect(() => {
if (sessions.length > 0 && !selectedSession) {
const activeSession = sessions.find(s => s.is_active === 1);
if (activeSession) {
loadSessionGames(activeSession.id);
}
}
}, [sessions, selectedSession]);
// Poll for session list updates (to detect when sessions end/start)
useEffect(() => {
const interval = setInterval(() => {
loadSessions();
}, 3000);
return () => clearInterval(interval);
}, [loadSessions]);
// Poll for updates on active session games
useEffect(() => {
if (!selectedSession) return;
const currentSession = sessions.find(s => s.id === selectedSession);
if (!currentSession || currentSession.is_active !== 1) return;
// Refresh games every 3 seconds for active session
const interval = setInterval(() => {
refreshSessionGames(selectedSession, true); // silent refresh
}, 3000);
return () => clearInterval(interval);
}, [selectedSession, sessions, refreshSessionGames]);
const handleExport = async (sessionId, format) => {
try {
const response = await api.get(`/sessions/${sessionId}/export?format=${format}`, {
responseType: 'blob'
});
// Create download link
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `session-${sessionId}.${format === 'json' ? 'json' : 'txt'}`);
document.body.appendChild(link);
link.click();
link.parentNode.removeChild(link);
window.URL.revokeObjectURL(url);
success(`Session exported as ${format.toUpperCase()}`);
} catch (err) {
console.error('Failed to export session', err);
error('Failed to export session');
}
};
const loadSessionGames = async (sessionId, silent = false) => {
try {
const response = await api.get(`/sessions/${sessionId}/games`);
// Reverse chronological order (most recent first) - create new array to avoid mutation
setSessionGames([...response.data].reverse());
if (!silent) {
setSelectedSession(sessionId);
}
} catch (err) {
if (!silent) {
console.error('Failed to load session games', err);
}
}
};
const handleCloseSession = async (sessionId, notes) => {
try {
await api.post(`/sessions/${sessionId}/close`, { notes });
await loadSessions();
setClosingSession(null);
if (selectedSession === sessionId) {
// Reload the session details to show updated state
loadSessionGames(sessionId);
}
success('Session ended successfully');
} catch (err) {
error('Failed to close session');
}
};
const handleDeleteSession = async (sessionId) => {
try {
await api.delete(`/sessions/${sessionId}`);
await loadSessions();
setDeletingSession(null);
if (selectedSession === sessionId) {
setSelectedSession(null);
setSessionGames([]);
}
success('Session deleted successfully');
} catch (err) {
error('Failed to delete session: ' + (err.response?.data?.error || err.message));
}
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="text-xl text-gray-600 dark:text-gray-400">Loading...</div>
</div>
);
}
return (
<div className="max-w-7xl mx-auto">
<h1 className="text-4xl font-bold mb-8 text-gray-800 dark:text-gray-100">Session History</h1>
<div className="grid md:grid-cols-3 gap-6">
{/* Sessions List */}
<div className="md:col-span-1">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-100">Sessions</h2>
{sessions.length > 3 && (
<button
onClick={() => setShowAllSessions(!showAllSessions)}
className="text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 transition"
>
{showAllSessions ? 'Show Recent' : `Show All (${sessions.length})`}
</button>
)}
</div>
{sessions.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400">No sessions found</p>
) : (
<div className="space-y-1 max-h-[600px] overflow-y-auto">
{(showAllSessions ? sessions : sessions.slice(0, 3)).map(session => (
<div
key={session.id}
className={`border rounded-lg transition ${
selectedSession === session.id
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/30'
: 'border-gray-300 dark:border-gray-600 hover:border-indigo-300 dark:hover:border-indigo-600'
}`}
>
{/* Main session info - clickable */}
<div
onClick={() => loadSessionGames(session.id)}
className="p-3 cursor-pointer"
>
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-sm text-gray-800 dark:text-gray-100">
Session #{session.id}
</span>
{session.is_active === 1 && (
<span className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs px-2 py-0.5 rounded flex-shrink-0">
Active
</span>
)}
</div>
<div className="flex flex-wrap gap-x-2 text-xs text-gray-500 dark:text-gray-400">
<span>{formatLocalDate(session.created_at)}</span>
<span></span>
<span>{session.games_played} game{session.games_played !== 1 ? 's' : ''}</span>
</div>
</div>
</div>
</div>
{/* Action buttons for authenticated users */}
{isAuthenticated && (
<div className="px-3 pb-3 pt-0 flex gap-2">
{session.is_active === 1 ? (
<button
onClick={(e) => {
e.stopPropagation();
setClosingSession(session.id);
}}
className="w-full bg-orange-600 dark:bg-orange-700 text-white px-4 py-2 rounded text-sm hover:bg-orange-700 dark:hover:bg-orange-800 transition"
>
End Session
</button>
) : (
<button
onClick={(e) => {
e.stopPropagation();
setDeletingSession(session.id);
}}
className="w-full bg-red-600 dark:bg-red-700 text-white px-4 py-2 rounded text-sm hover:bg-red-700 dark:hover:bg-red-800 transition"
>
Delete Session
</button>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
{/* Session Details */}
<div className="md:col-span-2">
{selectedSession ? (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-6">
<div className="flex flex-col gap-4 mb-6">
<div className="flex-1">
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-2">
<h2 className="text-xl sm:text-2xl font-semibold text-gray-800 dark:text-gray-100">
Session #{selectedSession}
</h2>
{sessions.find(s => s.id === selectedSession)?.is_active === 1 && (
<span className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs sm:text-sm px-2 sm:px-3 py-1 rounded-full font-semibold animate-pulse inline-flex items-center gap-1 w-fit">
🟢 Active
</span>
)}
</div>
<p className="text-sm sm:text-base text-gray-600 dark:text-gray-400">
{sessions.find(s => s.id === selectedSession)?.created_at &&
formatLocalDateTime(sessions.find(s => s.id === selectedSession).created_at)}
</p>
{sessions.find(s => s.id === selectedSession)?.is_active === 1 && (
<p className="text-xs sm:text-sm text-gray-500 dark:text-gray-500 mt-1 italic">
Games update automatically
</p>
)}
</div>
<div className="flex flex-col sm:flex-row gap-2">
{isAuthenticated && sessions.find(s => s.id === selectedSession)?.is_active === 1 && (
<button
onClick={() => setShowChatImport(true)}
className="bg-indigo-600 dark:bg-indigo-700 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 transition text-sm sm:text-base w-full sm:w-auto"
>
Import Chat Log
</button>
)}
{isAuthenticated && (
<>
<button
onClick={() => handleExport(selectedSession, 'txt')}
className="bg-gray-600 dark:bg-gray-700 text-white px-4 py-2 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition text-sm sm:text-base w-full sm:w-auto"
>
Export as Text
</button>
<button
onClick={() => handleExport(selectedSession, 'json')}
className="bg-gray-600 dark:bg-gray-700 text-white px-4 py-2 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition text-sm sm:text-base w-full sm:w-auto"
>
Export as JSON
</button>
</>
)}
</div>
</div>
{showChatImport && (
<ChatImportPanel
sessionId={selectedSession}
onClose={() => setShowChatImport(false)}
onImportComplete={() => {
loadSessionGames(selectedSession);
setShowChatImport(false);
}}
/>
)}
{sessionGames.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400">No games played in this session</p>
) : (
<div>
<h3 className="text-xl font-semibold mb-4 text-gray-700 dark:text-gray-200">
Games Played ({sessionGames.length})
</h3>
<div className="space-y-3">
{sessionGames.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">
{sessionGames.length - index}. {game.title}
</div>
<div className="text-gray-600 dark:text-gray-400">{game.pack_name}</div>
</div>
<div className="text-right">
<div className="text-sm text-gray-500 dark:text-gray-400">
{formatLocalTime(game.played_at)}
</div>
{game.manually_added === 1 && (
<span className="inline-block mt-1 text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded">
Manual
</span>
)}
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm text-gray-600 dark:text-gray-400">
<div>
<span className="font-semibold">Players:</span> {game.min_players}-{game.max_players}
</div>
<div>
<span className="font-semibold">Type:</span> {game.game_type || 'N/A'}
</div>
<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>
))}
</div>
</div>
)}
</div>
) : (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 flex items-center justify-center h-64">
<p className="text-gray-500 dark:text-gray-400 text-lg">Select a session to view details</p>
</div>
)}
</div>
</div>
{/* End Session Modal */}
{closingSession && (
<EndSessionModal
sessionId={closingSession}
sessionGames={closingSession === selectedSession ? sessionGames : []}
onClose={() => setClosingSession(null)}
onConfirm={handleCloseSession}
onShowChatImport={() => {
setShowChatImport(true);
if (closingSession !== selectedSession) {
loadSessionGames(closingSession);
}
}}
/>
)}
{/* Delete Confirmation Modal */}
{deletingSession && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-8 max-w-md w-full">
<h2 className="text-2xl font-bold mb-4 text-red-600 dark:text-red-400">Delete Session?</h2>
<p className="text-gray-700 dark:text-gray-300 mb-6">
Are you sure you want to delete Session #{deletingSession}?
This will permanently delete all games and chat logs associated with this session. This action cannot be undone.
</p>
<div className="flex gap-4">
<button
onClick={() => handleDeleteSession(deletingSession)}
className="flex-1 bg-red-600 dark:bg-red-700 text-white py-3 rounded-lg hover:bg-red-700 dark:hover:bg-red-800 transition font-semibold"
>
Delete Permanently
</button>
<button
onClick={() => setDeletingSession(null)}
className="flex-1 bg-gray-600 dark:bg-gray-700 text-white py-3 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition"
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
);
}
function EndSessionModal({ sessionId, sessionGames, onClose, onConfirm, onShowChatImport }) {
const [notes, setNotes] = useState('');
// Check if any games have been voted on (popularity != 0)
const hasPopularityData = sessionGames.some(game => game.popularity_score !== 0);
const showPopularityWarning = sessionGames.length > 0 && !hasPopularityData;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-8 max-w-md w-full">
<h2 className="text-2xl font-bold mb-4 dark:text-gray-100">End Session #{sessionId}</h2>
{/* Popularity Warning */}
{showPopularityWarning && (
<div className="mb-4 p-4 bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-300 dark:border-yellow-700 rounded-lg">
<div className="flex items-start gap-2">
<span className="text-yellow-600 dark:text-yellow-400 text-xl"></span>
<div className="flex-1">
<p className="font-semibold text-yellow-800 dark:text-yellow-200 mb-1">
No Popularity Data
</p>
<p className="text-sm text-yellow-700 dark:text-yellow-300 mb-3">
You haven't imported chat reactions yet. Import now to track which games your players loved!
</p>
<button
onClick={() => {
onClose();
onShowChatImport();
}}
className="text-sm bg-yellow-600 dark:bg-yellow-700 text-white px-4 py-2 rounded hover:bg-yellow-700 dark:hover:bg-yellow-800 transition"
>
Import Chat Log
</button>
</div>
</div>
</div>
)}
<div className="mb-4">
<label className="block text-gray-700 dark:text-gray-300 font-semibold mb-2">
Session Notes (optional)
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg h-32 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="Add any notes about this session..."
/>
</div>
<div className="flex gap-4">
<button
onClick={() => onConfirm(sessionId, notes)}
className="flex-1 bg-orange-600 dark:bg-orange-700 text-white py-3 rounded-lg hover:bg-orange-700 dark:hover:bg-orange-800 transition"
>
End Session
</button>
<button
onClick={onClose}
className="flex-1 bg-gray-600 dark:bg-gray-700 text-white py-3 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition"
>
Cancel
</button>
</div>
</div>
</div>
);
}
function ChatImportPanel({ sessionId, onClose, onImportComplete }) {
const [chatData, setChatData] = useState('');
const [importing, setImporting] = useState(false);
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 or upload a file');
return;
}
setImporting(true);
setResult(null);
try {
const parsedData = JSON.parse(chatData);
const response = await api.post(`/sessions/${sessionId}/chat-import`, {
chatData: parsedData
});
setResult(response.data);
success('Chat log imported successfully');
setTimeout(() => {
onImportComplete();
}, 2000);
} catch (err) {
error('Import failed: ' + (err.response?.data?.error || err.message));
} finally {
setImporting(false);
}
};
return (
<div className="bg-gray-50 dark:bg-gray-700/50 border border-gray-300 dark:border-gray-600 rounded-lg p-6 mb-6">
<h3 className="text-xl font-semibold mb-4 dark:text-gray-100">Import Chat Log</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
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">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)}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg h-48 font-mono text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
placeholder='[{"username":"Alice","message":"thisgame++","timestamp":"2024-01-01T12:00:00Z"}]'
disabled={importing}
/>
</div>
{result && (
<div className="mb-4 p-4 bg-green-50 dark:bg-green-900/30 border border-green-300 dark:border-green-700 rounded-lg">
<p className="font-semibold text-green-800 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">
<p className="font-semibold">Votes by game:</p>
<ul className="list-disc list-inside">
{Object.values(result.votesByGame).map((vote, i) => (
<li key={i}>
{vote.title}: +{vote.upvotes} / -{vote.downvotes}
</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>
)}
<div className="flex gap-4">
<button
onClick={handleImport}
disabled={importing}
className="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition disabled:bg-gray-400 dark:disabled:bg-gray-600"
>
{importing ? 'Importing...' : 'Import'}
</button>
<button
onClick={onClose}
className="bg-gray-600 dark:bg-gray-700 text-white px-6 py-2 rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition"
>
Close
</button>
</div>
</div>
);
}
export default History;