2025-10-30 17:52:44 -04:00
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
2025-10-30 04:27:43 -04:00
|
|
|
|
import { useAuth } from '../context/AuthContext';
|
2025-10-30 13:27:55 -04:00
|
|
|
|
import { useToast } from '../components/Toast';
|
2025-10-30 04:27:43 -04:00
|
|
|
|
import api from '../api/axios';
|
2025-10-30 13:27:55 -04:00
|
|
|
|
import { formatLocalDateTime, formatLocalDate, formatLocalTime } from '../utils/dateUtils';
|
2025-10-30 17:18:30 -04:00
|
|
|
|
import PopularityBadge from '../components/PopularityBadge';
|
2025-10-30 04:27:43 -04:00
|
|
|
|
|
|
|
|
|
|
function History() {
|
|
|
|
|
|
const { isAuthenticated } = useAuth();
|
2025-10-30 13:27:55 -04:00
|
|
|
|
const { error, success } = useToast();
|
2025-10-30 04:27:43 -04:00
|
|
|
|
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);
|
2025-10-30 13:27:55 -04:00
|
|
|
|
const [showAllSessions, setShowAllSessions] = useState(false);
|
|
|
|
|
|
const [deletingSession, setDeletingSession] = useState(null);
|
2025-10-30 04:27:43 -04:00
|
|
|
|
|
2025-10-30 17:52:44 -04:00
|
|
|
|
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)
|
|
|
|
|
|
setSessionGames(response.data.reverse());
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (!silent) {
|
|
|
|
|
|
console.error('Failed to load session games', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2025-10-30 04:27:43 -04:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadSessions();
|
2025-10-30 17:52:44 -04:00
|
|
|
|
}, [loadSessions]);
|
2025-10-30 04:27:43 -04:00
|
|
|
|
|
2025-10-30 13:27:55 -04:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-10-30 17:52:44 -04:00
|
|
|
|
}, [sessions, selectedSession]);
|
2025-10-30 13:27:55 -04:00
|
|
|
|
|
2025-10-30 17:34:44 -04:00
|
|
|
|
// Poll for session list updates (to detect when sessions end/start)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const interval = setInterval(() => {
|
|
|
|
|
|
loadSessions();
|
|
|
|
|
|
}, 3000);
|
|
|
|
|
|
|
|
|
|
|
|
return () => clearInterval(interval);
|
2025-10-30 17:52:44 -04:00
|
|
|
|
}, [loadSessions]);
|
2025-10-30 17:34:44 -04:00
|
|
|
|
|
|
|
|
|
|
// Poll for updates on active session games
|
2025-10-30 13:27:55 -04:00
|
|
|
|
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(() => {
|
2025-10-30 17:52:44 -04:00
|
|
|
|
refreshSessionGames(selectedSession, true); // silent refresh
|
2025-10-30 13:27:55 -04:00
|
|
|
|
}, 3000);
|
|
|
|
|
|
|
|
|
|
|
|
return () => clearInterval(interval);
|
2025-10-30 17:52:44 -04:00
|
|
|
|
}, [selectedSession, sessions, refreshSessionGames]);
|
2025-10-30 04:27:43 -04:00
|
|
|
|
|
2025-10-30 15:17:15 -04:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-30 13:27:55 -04:00
|
|
|
|
const loadSessionGames = async (sessionId, silent = false) => {
|
2025-10-30 04:27:43 -04:00
|
|
|
|
try {
|
|
|
|
|
|
const response = await api.get(`/sessions/${sessionId}/games`);
|
|
|
|
|
|
setSessionGames(response.data);
|
2025-10-30 13:27:55 -04:00
|
|
|
|
if (!silent) {
|
|
|
|
|
|
setSelectedSession(sessionId);
|
|
|
|
|
|
}
|
2025-10-30 04:27:43 -04:00
|
|
|
|
} catch (err) {
|
2025-10-30 13:27:55 -04:00
|
|
|
|
if (!silent) {
|
|
|
|
|
|
console.error('Failed to load session games', err);
|
|
|
|
|
|
}
|
2025-10-30 04:27:43 -04:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleCloseSession = async (sessionId, notes) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.post(`/sessions/${sessionId}/close`, { notes });
|
|
|
|
|
|
await loadSessions();
|
|
|
|
|
|
setClosingSession(null);
|
2025-10-30 13:27:55 -04:00
|
|
|
|
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);
|
2025-10-30 04:27:43 -04:00
|
|
|
|
if (selectedSession === sessionId) {
|
|
|
|
|
|
setSelectedSession(null);
|
|
|
|
|
|
setSessionGames([]);
|
|
|
|
|
|
}
|
2025-10-30 13:27:55 -04:00
|
|
|
|
success('Session deleted successfully');
|
2025-10-30 04:27:43 -04:00
|
|
|
|
} catch (err) {
|
2025-10-30 13:27:55 -04:00
|
|
|
|
error('Failed to delete session: ' + (err.response?.data?.error || err.message));
|
2025-10-30 04:27:43 -04:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex justify-center items-center h-64">
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<div className="text-xl text-gray-600 dark:text-gray-400">Loading...</div>
|
2025-10-30 04:27:43 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="max-w-7xl mx-auto">
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<h1 className="text-4xl font-bold mb-8 text-gray-800 dark:text-gray-100">Session History</h1>
|
2025-10-30 04:27:43 -04:00
|
|
|
|
|
|
|
|
|
|
<div className="grid md:grid-cols-3 gap-6">
|
|
|
|
|
|
{/* Sessions List */}
|
|
|
|
|
|
<div className="md:col-span-1">
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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>
|
2025-10-30 04:27:43 -04:00
|
|
|
|
|
|
|
|
|
|
{sessions.length === 0 ? (
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<p className="text-gray-500 dark:text-gray-400">No sessions found</p>
|
2025-10-30 04:27:43 -04:00
|
|
|
|
) : (
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<div className="space-y-1 max-h-[600px] overflow-y-auto">
|
|
|
|
|
|
{(showAllSessions ? sessions : sessions.slice(0, 3)).map(session => (
|
2025-10-30 04:27:43 -04:00
|
|
|
|
<div
|
|
|
|
|
|
key={session.id}
|
2025-10-30 13:27:55 -04:00
|
|
|
|
className={`border rounded-lg transition ${
|
2025-10-30 04:27:43 -04:00
|
|
|
|
selectedSession === session.id
|
2025-10-30 13:27:55 -04:00
|
|
|
|
? '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'
|
2025-10-30 04:27:43 -04:00
|
|
|
|
}`}
|
|
|
|
|
|
>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
{/* 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>
|
2025-10-30 04:27:43 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-10-30 13:27:55 -04:00
|
|
|
|
{/* 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>
|
2025-10-30 04:27:43 -04:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Session Details */}
|
|
|
|
|
|
<div className="md:col-span-2">
|
|
|
|
|
|
{selectedSession ? (
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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">
|
2025-10-30 04:27:43 -04:00
|
|
|
|
{sessions.find(s => s.id === selectedSession)?.created_at &&
|
2025-10-30 13:27:55 -04:00
|
|
|
|
formatLocalDateTime(sessions.find(s => s.id === selectedSession).created_at)}
|
2025-10-30 04:27:43 -04:00
|
|
|
|
</p>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2025-10-30 04:27:43 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-10-30 15:17:15 -04:00
|
|
|
|
<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>
|
2025-10-30 04:27:43 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{showChatImport && (
|
|
|
|
|
|
<ChatImportPanel
|
|
|
|
|
|
sessionId={selectedSession}
|
|
|
|
|
|
onClose={() => setShowChatImport(false)}
|
|
|
|
|
|
onImportComplete={() => {
|
|
|
|
|
|
loadSessionGames(selectedSession);
|
|
|
|
|
|
setShowChatImport(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{sessionGames.length === 0 ? (
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<p className="text-gray-500 dark:text-gray-400">No games played in this session</p>
|
2025-10-30 04:27:43 -04:00
|
|
|
|
) : (
|
|
|
|
|
|
<div>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<h3 className="text-xl font-semibold mb-4 text-gray-700 dark:text-gray-200">
|
2025-10-30 04:27:43 -04:00
|
|
|
|
Games Played ({sessionGames.length})
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<div className="space-y-3">
|
2025-10-30 17:18:30 -04:00
|
|
|
|
{[...sessionGames].reverse().map((game, index) => (
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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">
|
2025-10-30 04:27:43 -04:00
|
|
|
|
<div className="flex justify-between items-start mb-2">
|
|
|
|
|
|
<div>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<div className="font-semibold text-lg text-gray-800 dark:text-gray-100">
|
2025-10-30 17:18:30 -04:00
|
|
|
|
{sessionGames.length - index}. {game.title}
|
2025-10-30 04:27:43 -04:00
|
|
|
|
</div>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<div className="text-gray-600 dark:text-gray-400">{game.pack_name}</div>
|
2025-10-30 04:27:43 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-right">
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
|
|
|
|
{formatLocalTime(game.played_at)}
|
2025-10-30 04:27:43 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
{game.manually_added === 1 && (
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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">
|
2025-10-30 04:27:43 -04:00
|
|
|
|
Manual
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm text-gray-600 dark:text-gray-400">
|
2025-10-30 04:27:43 -04:00
|
|
|
|
<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>
|
2025-10-30 17:18:30 -04:00
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<span
|
|
|
|
|
|
className="font-semibold"
|
|
|
|
|
|
title="Popularity is cumulative across all sessions where this game was played"
|
|
|
|
|
|
>
|
|
|
|
|
|
Popularity:
|
2025-10-30 04:27:43 -04:00
|
|
|
|
</span>
|
2025-10-30 17:18:30 -04:00
|
|
|
|
<PopularityBadge
|
|
|
|
|
|
upvotes={game.upvotes || 0}
|
|
|
|
|
|
downvotes={game.downvotes || 0}
|
|
|
|
|
|
popularityScore={game.popularity_score || 0}
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
showCounts={true}
|
|
|
|
|
|
showNet={true}
|
|
|
|
|
|
showRatio={true}
|
|
|
|
|
|
/>
|
2025-10-30 04:27:43 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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>
|
2025-10-30 04:27:43 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-10-30 13:27:55 -04:00
|
|
|
|
{/* End Session Modal */}
|
2025-10-30 04:27:43 -04:00
|
|
|
|
{closingSession && (
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<EndSessionModal
|
2025-10-30 04:27:43 -04:00
|
|
|
|
sessionId={closingSession}
|
2025-10-30 13:27:55 -04:00
|
|
|
|
sessionGames={closingSession === selectedSession ? sessionGames : []}
|
2025-10-30 04:27:43 -04:00
|
|
|
|
onClose={() => setClosingSession(null)}
|
|
|
|
|
|
onConfirm={handleCloseSession}
|
2025-10-30 13:27:55 -04:00
|
|
|
|
onShowChatImport={() => {
|
|
|
|
|
|
setShowChatImport(true);
|
|
|
|
|
|
if (closingSession !== selectedSession) {
|
|
|
|
|
|
loadSessionGames(closingSession);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
2025-10-30 04:27:43 -04:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2025-10-30 13:27:55 -04:00
|
|
|
|
|
|
|
|
|
|
{/* 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>
|
|
|
|
|
|
)}
|
2025-10-30 04:27:43 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-30 13:27:55 -04:00
|
|
|
|
function EndSessionModal({ sessionId, sessionGames, onClose, onConfirm, onShowChatImport }) {
|
2025-10-30 04:27:43 -04:00
|
|
|
|
const [notes, setNotes] = useState('');
|
2025-10-30 13:27:55 -04:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
2025-10-30 04:27:43 -04:00
|
|
|
|
|
|
|
|
|
|
return (
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2025-10-30 04:27:43 -04:00
|
|
|
|
|
|
|
|
|
|
<div className="mb-4">
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<label className="block text-gray-700 dark:text-gray-300 font-semibold mb-2">
|
2025-10-30 04:27:43 -04:00
|
|
|
|
Session Notes (optional)
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
value={notes}
|
|
|
|
|
|
onChange={(e) => setNotes(e.target.value)}
|
2025-10-30 13:27:55 -04:00
|
|
|
|
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"
|
2025-10-30 04:27:43 -04:00
|
|
|
|
placeholder="Add any notes about this session..."
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-4">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => onConfirm(sessionId, notes)}
|
2025-10-30 13:27:55 -04:00
|
|
|
|
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"
|
2025-10-30 04:27:43 -04:00
|
|
|
|
>
|
2025-10-30 13:27:55 -04:00
|
|
|
|
End Session
|
2025-10-30 04:27:43 -04:00
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={onClose}
|
2025-10-30 13:27:55 -04:00
|
|
|
|
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"
|
2025-10-30 04:27:43 -04:00
|
|
|
|
>
|
|
|
|
|
|
Cancel
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ChatImportPanel({ sessionId, onClose, onImportComplete }) {
|
|
|
|
|
|
const [chatData, setChatData] = useState('');
|
|
|
|
|
|
const [importing, setImporting] = useState(false);
|
|
|
|
|
|
const [result, setResult] = useState(null);
|
2025-10-30 13:27:55 -04:00
|
|
|
|
const { error, success } = useToast();
|
2025-10-30 04:27:43 -04:00
|
|
|
|
|
2025-10-30 17:18:30 -04:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-30 04:27:43 -04:00
|
|
|
|
const handleImport = async () => {
|
|
|
|
|
|
if (!chatData.trim()) {
|
2025-10-30 17:18:30 -04:00
|
|
|
|
error('Please enter chat data or upload a file');
|
2025-10-30 04:27:43 -04:00
|
|
|
|
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);
|
2025-10-30 13:27:55 -04:00
|
|
|
|
success('Chat log imported successfully');
|
2025-10-30 04:27:43 -04:00
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
onImportComplete();
|
|
|
|
|
|
}, 2000);
|
|
|
|
|
|
} catch (err) {
|
2025-10-30 13:27:55 -04:00
|
|
|
|
error('Import failed: ' + (err.response?.data?.error || err.message));
|
2025-10-30 04:27:43 -04:00
|
|
|
|
} finally {
|
|
|
|
|
|
setImporting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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>
|
2025-10-30 04:27:43 -04:00
|
|
|
|
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
2025-10-30 17:18:30 -04:00
|
|
|
|
Upload a JSON file or paste JSON array with format: [{"{"}"username": "...", "message": "...", "timestamp": "..."{"}"}]
|
2025-10-30 04:27:43 -04:00
|
|
|
|
<br />
|
|
|
|
|
|
The system will detect "thisgame++" and "thisgame--" patterns and update game popularity.
|
2025-10-30 17:18:30 -04:00
|
|
|
|
<br />
|
|
|
|
|
|
<span className="text-xs italic">
|
|
|
|
|
|
Note: Popularity is cumulative - votes are added to each game's all-time totals.
|
|
|
|
|
|
</span>
|
2025-10-30 04:27:43 -04:00
|
|
|
|
</p>
|
|
|
|
|
|
|
2025-10-30 17:18:30 -04:00
|
|
|
|
{/* File Upload */}
|
2025-10-30 04:27:43 -04:00
|
|
|
|
<div className="mb-4">
|
2025-10-30 17:18:30 -04:00
|
|
|
|
<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>
|
2025-10-30 04:27:43 -04:00
|
|
|
|
<textarea
|
|
|
|
|
|
value={chatData}
|
|
|
|
|
|
onChange={(e) => setChatData(e.target.value)}
|
2025-10-30 13:27:55 -04:00
|
|
|
|
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"
|
2025-10-30 04:27:43 -04:00
|
|
|
|
placeholder='[{"username":"Alice","message":"thisgame++","timestamp":"2024-01-01T12:00:00Z"}]'
|
|
|
|
|
|
disabled={importing}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{result && (
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<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">
|
2025-10-30 04:27:43 -04:00
|
|
|
|
Imported {result.messagesImported} messages, processed {result.votesProcessed} votes
|
2025-10-30 17:18:30 -04:00
|
|
|
|
{result.duplicatesSkipped > 0 && (
|
|
|
|
|
|
<span className="block mt-1 text-xs italic opacity-75">
|
|
|
|
|
|
({result.duplicatesSkipped} duplicate{result.duplicatesSkipped !== 1 ? 's' : ''} skipped)
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
2025-10-30 04:27:43 -04:00
|
|
|
|
</p>
|
|
|
|
|
|
{result.votesByGame && Object.keys(result.votesByGame).length > 0 && (
|
2025-10-30 13:27:55 -04:00
|
|
|
|
<div className="mt-2 text-sm text-green-700 dark:text-green-300">
|
2025-10-30 04:27:43 -04:00
|
|
|
|
<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>
|
2025-10-30 17:18:30 -04:00
|
|
|
|
<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>
|
2025-10-30 04:27:43 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-10-30 17:18:30 -04:00
|
|
|
|
|
|
|
|
|
|
{/* 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>
|
|
|
|
|
|
)}
|
2025-10-30 04:27:43 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-4">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={handleImport}
|
|
|
|
|
|
disabled={importing}
|
2025-10-30 13:27:55 -04:00
|
|
|
|
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"
|
2025-10-30 04:27:43 -04:00
|
|
|
|
>
|
|
|
|
|
|
{importing ? 'Importing...' : 'Import'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={onClose}
|
2025-10-30 13:27:55 -04:00
|
|
|
|
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"
|
2025-10-30 04:27:43 -04:00
|
|
|
|
>
|
|
|
|
|
|
Close
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default History;
|
|
|
|
|
|
|