357 lines
13 KiB
React
357 lines
13 KiB
React
|
|
import React, { useState, useEffect } from 'react';
|
||
|
|
import { useAuth } from '../context/AuthContext';
|
||
|
|
import api from '../api/axios';
|
||
|
|
|
||
|
|
function History() {
|
||
|
|
const { isAuthenticated } = useAuth();
|
||
|
|
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);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
loadSessions();
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const loadSessions = async () => {
|
||
|
|
try {
|
||
|
|
const response = await api.get('/sessions');
|
||
|
|
setSessions(response.data);
|
||
|
|
} catch (err) {
|
||
|
|
console.error('Failed to load sessions', err);
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const loadSessionGames = async (sessionId) => {
|
||
|
|
try {
|
||
|
|
const response = await api.get(`/sessions/${sessionId}/games`);
|
||
|
|
setSessionGames(response.data);
|
||
|
|
setSelectedSession(sessionId);
|
||
|
|
} catch (err) {
|
||
|
|
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) {
|
||
|
|
setSelectedSession(null);
|
||
|
|
setSessionGames([]);
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
alert('Failed to close session');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
if (loading) {
|
||
|
|
return (
|
||
|
|
<div className="flex justify-center items-center h-64">
|
||
|
|
<div className="text-xl text-gray-600">Loading...</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="max-w-7xl mx-auto">
|
||
|
|
<h1 className="text-4xl font-bold mb-8 text-gray-800">Session History</h1>
|
||
|
|
|
||
|
|
<div className="grid md:grid-cols-3 gap-6">
|
||
|
|
{/* Sessions List */}
|
||
|
|
<div className="md:col-span-1">
|
||
|
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
||
|
|
<h2 className="text-2xl font-semibold mb-4 text-gray-800">Sessions</h2>
|
||
|
|
|
||
|
|
{sessions.length === 0 ? (
|
||
|
|
<p className="text-gray-500">No sessions found</p>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-2 max-h-[600px] overflow-y-auto">
|
||
|
|
{sessions.map(session => (
|
||
|
|
<div
|
||
|
|
key={session.id}
|
||
|
|
onClick={() => loadSessionGames(session.id)}
|
||
|
|
className={`p-4 border rounded-lg cursor-pointer transition ${
|
||
|
|
selectedSession === session.id
|
||
|
|
? 'border-indigo-500 bg-indigo-50'
|
||
|
|
: 'border-gray-300 hover:border-indigo-300'
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
<div className="flex justify-between items-start mb-2">
|
||
|
|
<div className="font-semibold text-gray-800">
|
||
|
|
Session #{session.id}
|
||
|
|
</div>
|
||
|
|
{session.is_active === 1 && (
|
||
|
|
<span className="bg-green-100 text-green-800 text-xs px-2 py-1 rounded">
|
||
|
|
Active
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<div className="text-sm text-gray-600">
|
||
|
|
{new Date(session.created_at).toLocaleDateString()}
|
||
|
|
</div>
|
||
|
|
<div className="text-sm text-gray-500">
|
||
|
|
{session.games_played} game{session.games_played !== 1 ? 's' : ''} played
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{isAuthenticated && session.is_active === 1 && (
|
||
|
|
<button
|
||
|
|
onClick={(e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
setClosingSession(session.id);
|
||
|
|
}}
|
||
|
|
className="mt-2 w-full bg-yellow-600 text-white px-3 py-1 rounded text-sm hover:bg-yellow-700 transition"
|
||
|
|
>
|
||
|
|
Close Session
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Session Details */}
|
||
|
|
<div className="md:col-span-2">
|
||
|
|
{selectedSession ? (
|
||
|
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
||
|
|
<div className="flex justify-between items-start mb-6">
|
||
|
|
<div>
|
||
|
|
<h2 className="text-2xl font-semibold text-gray-800">
|
||
|
|
Session #{selectedSession}
|
||
|
|
</h2>
|
||
|
|
<p className="text-gray-600">
|
||
|
|
{sessions.find(s => s.id === selectedSession)?.created_at &&
|
||
|
|
new Date(sessions.find(s => s.id === selectedSession).created_at).toLocaleString()}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{isAuthenticated && sessions.find(s => s.id === selectedSession)?.is_active === 1 && (
|
||
|
|
<button
|
||
|
|
onClick={() => setShowChatImport(true)}
|
||
|
|
className="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition"
|
||
|
|
>
|
||
|
|
Import Chat Log
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{showChatImport && (
|
||
|
|
<ChatImportPanel
|
||
|
|
sessionId={selectedSession}
|
||
|
|
onClose={() => setShowChatImport(false)}
|
||
|
|
onImportComplete={() => {
|
||
|
|
loadSessionGames(selectedSession);
|
||
|
|
setShowChatImport(false);
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{sessionGames.length === 0 ? (
|
||
|
|
<p className="text-gray-500">No games played in this session</p>
|
||
|
|
) : (
|
||
|
|
<div>
|
||
|
|
<h3 className="text-xl font-semibold mb-4 text-gray-700">
|
||
|
|
Games Played ({sessionGames.length})
|
||
|
|
</h3>
|
||
|
|
<div className="space-y-3">
|
||
|
|
{sessionGames.map((game, index) => (
|
||
|
|
<div key={game.id} className="border border-gray-200 rounded-lg p-4">
|
||
|
|
<div className="flex justify-between items-start mb-2">
|
||
|
|
<div>
|
||
|
|
<div className="font-semibold text-lg text-gray-800">
|
||
|
|
{index + 1}. {game.title}
|
||
|
|
</div>
|
||
|
|
<div className="text-gray-600">{game.pack_name}</div>
|
||
|
|
</div>
|
||
|
|
<div className="text-right">
|
||
|
|
<div className="text-sm text-gray-500">
|
||
|
|
{new Date(game.played_at).toLocaleTimeString()}
|
||
|
|
</div>
|
||
|
|
{game.manually_added === 1 && (
|
||
|
|
<span className="inline-block mt-1 text-xs bg-yellow-100 text-yellow-800 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">
|
||
|
|
<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>
|
||
|
|
<span className="font-semibold">Popularity:</span>{' '}
|
||
|
|
<span className={game.popularity_score >= 0 ? 'text-green-600' : 'text-red-600'}>
|
||
|
|
{game.popularity_score > 0 ? '+' : ''}{game.popularity_score}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="bg-white rounded-lg shadow-lg p-6 flex items-center justify-center h-64">
|
||
|
|
<p className="text-gray-500 text-lg">Select a session to view details</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Close Session Modal */}
|
||
|
|
{closingSession && (
|
||
|
|
<CloseSessionModal
|
||
|
|
sessionId={closingSession}
|
||
|
|
onClose={() => setClosingSession(null)}
|
||
|
|
onConfirm={handleCloseSession}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function CloseSessionModal({ sessionId, onClose, onConfirm }) {
|
||
|
|
const [notes, setNotes] = useState('');
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
|
|
<div className="bg-white rounded-lg p-8 max-w-md w-full">
|
||
|
|
<h2 className="text-2xl font-bold mb-4">Close Session #{sessionId}</h2>
|
||
|
|
|
||
|
|
<div className="mb-4">
|
||
|
|
<label className="block text-gray-700 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 rounded-lg h-32"
|
||
|
|
placeholder="Add any notes about this session..."
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex gap-4">
|
||
|
|
<button
|
||
|
|
onClick={() => onConfirm(sessionId, notes)}
|
||
|
|
className="flex-1 bg-indigo-600 text-white py-3 rounded-lg hover:bg-indigo-700 transition"
|
||
|
|
>
|
||
|
|
Close Session
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
onClick={onClose}
|
||
|
|
className="flex-1 bg-gray-600 text-white py-3 rounded-lg hover:bg-gray-700 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 handleImport = async () => {
|
||
|
|
if (!chatData.trim()) {
|
||
|
|
alert('Please enter chat data');
|
||
|
|
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);
|
||
|
|
setTimeout(() => {
|
||
|
|
onImportComplete();
|
||
|
|
}, 2000);
|
||
|
|
} catch (err) {
|
||
|
|
alert('Import failed: ' + (err.response?.data?.error || err.message));
|
||
|
|
} finally {
|
||
|
|
setImporting(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="bg-gray-50 border border-gray-300 rounded-lg p-6 mb-6">
|
||
|
|
<h3 className="text-xl font-semibold mb-4">Import Chat Log</h3>
|
||
|
|
|
||
|
|
<p className="text-sm text-gray-600 mb-4">
|
||
|
|
Paste JSON array with format: [{"{"}"username": "...", "message": "...", "timestamp": "..."{"}"}]
|
||
|
|
<br />
|
||
|
|
The system will detect "thisgame++" and "thisgame--" patterns and update game popularity.
|
||
|
|
</p>
|
||
|
|
|
||
|
|
<div className="mb-4">
|
||
|
|
<label className="block text-gray-700 font-semibold mb-2">Chat JSON Data</label>
|
||
|
|
<textarea
|
||
|
|
value={chatData}
|
||
|
|
onChange={(e) => setChatData(e.target.value)}
|
||
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg h-48 font-mono text-sm"
|
||
|
|
placeholder='[{"username":"Alice","message":"thisgame++","timestamp":"2024-01-01T12:00:00Z"}]'
|
||
|
|
disabled={importing}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{result && (
|
||
|
|
<div className="mb-4 p-4 bg-green-50 border border-green-300 rounded-lg">
|
||
|
|
<p className="font-semibold text-green-800">Import Successful!</p>
|
||
|
|
<p className="text-sm text-green-700">
|
||
|
|
Imported {result.messagesImported} messages, processed {result.votesProcessed} votes
|
||
|
|
</p>
|
||
|
|
{result.votesByGame && Object.keys(result.votesByGame).length > 0 && (
|
||
|
|
<div className="mt-2 text-sm">
|
||
|
|
<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>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</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"
|
||
|
|
>
|
||
|
|
{importing ? 'Importing...' : 'Import'}
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
onClick={onClose}
|
||
|
|
className="bg-gray-600 text-white px-6 py-2 rounded-lg hover:bg-gray-700 transition"
|
||
|
|
>
|
||
|
|
Close
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export default History;
|
||
|
|
|