diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index a2cc22a..cc43b4f 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -12,6 +12,7 @@ import Login from './pages/Login';
import Picker from './pages/Picker';
import Manager from './pages/Manager';
import History from './pages/History';
+import SessionDetail from './pages/SessionDetail';
function App() {
const { isAuthenticated, logout } = useAuth();
@@ -161,6 +162,7 @@ function App() {
} />
} />
} />
+ } />
} />
} />
diff --git a/frontend/src/pages/SessionDetail.jsx b/frontend/src/pages/SessionDetail.jsx
new file mode 100644
index 0000000..235007e
--- /dev/null
+++ b/frontend/src/pages/SessionDetail.jsx
@@ -0,0 +1,624 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { useParams, useNavigate, Link } from 'react-router-dom';
+import Markdown from 'react-markdown';
+import { useAuth } from '../context/AuthContext';
+import { useToast } from '../components/Toast';
+import api from '../api/axios';
+import { formatLocalDateTime, formatLocalTime } from '../utils/dateUtils';
+import PopularityBadge from '../components/PopularityBadge';
+
+function SessionDetail() {
+ const { id } = useParams();
+ const navigate = useNavigate();
+ const { isAuthenticated } = useAuth();
+ const { error: showError, success } = useToast();
+
+ const [session, setSession] = useState(null);
+ const [games, setGames] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [editing, setEditing] = useState(false);
+ const [editedNotes, setEditedNotes] = useState('');
+ const [saving, setSaving] = useState(false);
+ const [showDeleteNotesConfirm, setShowDeleteNotesConfirm] = useState(false);
+ const [showDeleteSessionConfirm, setShowDeleteSessionConfirm] = useState(false);
+ const [showChatImport, setShowChatImport] = useState(false);
+ const [closingSession, setClosingSession] = useState(false);
+
+ const loadSession = useCallback(async () => {
+ try {
+ const res = await api.get(`/sessions/${id}`);
+ setSession(res.data);
+ } catch (err) {
+ if (err.response?.status === 404) {
+ navigate('/history', { replace: true });
+ }
+ console.error('Failed to load session', err);
+ }
+ }, [id, navigate]);
+
+ const loadGames = useCallback(async () => {
+ try {
+ const res = await api.get(`/sessions/${id}/games`);
+ setGames([...res.data].reverse());
+ } catch (err) {
+ console.error('Failed to load session games', err);
+ }
+ }, [id]);
+
+ useEffect(() => {
+ Promise.all([loadSession(), loadGames()]).finally(() => setLoading(false));
+ }, [loadSession, loadGames]);
+
+ useEffect(() => {
+ if (!session || session.is_active !== 1) return;
+ const interval = setInterval(() => {
+ loadSession();
+ loadGames();
+ }, 3000);
+ return () => clearInterval(interval);
+ }, [session, loadSession, loadGames]);
+
+ const handleSaveNotes = async () => {
+ setSaving(true);
+ try {
+ await api.put(`/sessions/${id}/notes`, { notes: editedNotes });
+ await loadSession();
+ setEditing(false);
+ success('Notes saved');
+ } catch (err) {
+ showError('Failed to save notes');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleDeleteNotes = async () => {
+ try {
+ await api.delete(`/sessions/${id}/notes`);
+ await loadSession();
+ setEditing(false);
+ setShowDeleteNotesConfirm(false);
+ success('Notes deleted');
+ } catch (err) {
+ showError('Failed to delete notes');
+ }
+ };
+
+ const handleDeleteSession = async () => {
+ try {
+ await api.delete(`/sessions/${id}`);
+ success('Session deleted');
+ navigate('/history', { replace: true });
+ } catch (err) {
+ showError('Failed to delete session: ' + (err.response?.data?.error || err.message));
+ }
+ };
+
+ const handleCloseSession = async (sessionId, notes) => {
+ try {
+ await api.post(`/sessions/${sessionId}/close`, { notes });
+ await loadSession();
+ await loadGames();
+ setClosingSession(false);
+ success('Session ended successfully');
+ } catch (err) {
+ showError('Failed to close session');
+ }
+ };
+
+ const handleExport = async (format) => {
+ try {
+ const response = await api.get(`/sessions/${id}/export?format=${format}`, {
+ responseType: 'blob'
+ });
+ const url = window.URL.createObjectURL(new Blob([response.data]));
+ const link = document.createElement('a');
+ link.href = url;
+ link.setAttribute('download', `session-${id}.${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) {
+ showError('Failed to export session');
+ }
+ };
+
+ const startEditing = () => {
+ setEditedNotes(session.notes || '');
+ setEditing(true);
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!session) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ ← Back to History
+
+
+
+
+
+
+
+ Session #{session.id}
+
+ {session.is_active === 1 && (
+
+ 🟢 Active
+
+ )}
+
+
+ {formatLocalDateTime(session.created_at)}
+ {' • '}
+ {session.games_played} game{session.games_played !== 1 ? 's' : ''} played
+
+
+
+
+ {isAuthenticated && session.is_active === 1 && (
+ <>
+ setClosingSession(true)}
+ className="bg-orange-600 dark:bg-orange-700 text-white px-4 py-2 rounded-lg hover:bg-orange-700 dark:hover:bg-orange-800 transition text-sm"
+ >
+ End Session
+
+ 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"
+ >
+ Import Chat Log
+
+ >
+ )}
+ {isAuthenticated && (
+ <>
+ handleExport('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"
+ >
+ Export TXT
+
+ handleExport('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"
+ >
+ Export JSON
+
+ >
+ )}
+ {isAuthenticated && session.is_active === 0 && (
+ setShowDeleteSessionConfirm(true)}
+ className="bg-red-600 dark:bg-red-700 text-white px-4 py-2 rounded-lg hover:bg-red-700 dark:hover:bg-red-800 transition text-sm"
+ >
+ Delete Session
+
+ )}
+
+
+
+
+
+ setEditing(false)}
+ onDeleteNotes={handleDeleteNotes}
+ onShowDeleteConfirm={() => setShowDeleteNotesConfirm(true)}
+ onHideDeleteConfirm={() => setShowDeleteNotesConfirm(false)}
+ />
+
+
+ {showChatImport && (
+
+ setShowChatImport(false)}
+ onImportComplete={() => {
+ loadGames();
+ setShowChatImport(false);
+ }}
+ />
+
+ )}
+
+
+ {games.length === 0 ? (
+
No games played in this session
+ ) : (
+ <>
+
+ Games Played ({games.length})
+
+
+ {games.map((game, index) => (
+
+
+
+
+ {games.length - index}. {game.title}
+
+
{game.pack_name}
+
+
+
+ {formatLocalTime(game.played_at)}
+
+ {game.manually_added === 1 && (
+
+ Manual
+
+ )}
+
+
+
+
+ Players: {game.min_players}-{game.max_players}
+
+
+ Type: {game.game_type || 'N/A'}
+
+
+
+
+ ))}
+
+ >
+ )}
+
+
+ {closingSession && (
+
setClosingSession(false)}
+ onConfirm={handleCloseSession}
+ onShowChatImport={() => {
+ setShowChatImport(true);
+ setClosingSession(false);
+ }}
+ />
+ )}
+
+ {showDeleteSessionConfirm && (
+
+
+
Delete Session?
+
+ Are you sure you want to delete Session #{session.id}?
+ This will permanently delete all games and chat logs associated with this session. This action cannot be undone.
+
+
+
+ Delete Permanently
+
+ setShowDeleteSessionConfirm(false)}
+ 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
+
+
+
+
+ )}
+
+ );
+}
+
+function NotesSection({
+ session,
+ isAuthenticated,
+ editing,
+ editedNotes,
+ saving,
+ showDeleteNotesConfirm,
+ onStartEditing,
+ onSetEditedNotes,
+ onSave,
+ onCancel,
+ onDeleteNotes,
+ onShowDeleteConfirm,
+ onHideDeleteConfirm,
+}) {
+ if (editing) {
+ return (
+
+
+
Session Notes
+
+
+ Delete Notes
+
+
+ {saving ? 'Saving...' : 'Save'}
+
+
+ Cancel
+
+
+
+
+ );
+ }
+
+ if (!isAuthenticated) {
+ return (
+
+
Session Notes
+ {session.has_notes ? (
+ <>
+
{session.notes_preview}
+
Log in to view full notes
+ >
+ ) : (
+
No notes for this session
+ )}
+
+ );
+ }
+
+ return (
+
+
+
Session Notes
+
+ {session.notes ? 'Edit' : 'Add Notes'}
+
+
+ {session.notes ? (
+
+ {session.notes}
+
+ ) : (
+
No notes for this session
+ )}
+
+ );
+}
+
+function EndSessionModal({ sessionId, sessionGames, onClose, onConfirm, onShowChatImport }) {
+ const [notes, setNotes] = useState('');
+
+ const hasPopularityData = sessionGames.some(game => game.popularity_score !== 0);
+ const showPopularityWarning = sessionGames.length > 0 && !hasPopularityData;
+
+ return (
+
+
+
End Session #{sessionId}
+
+ {showPopularityWarning && (
+
+
+
⚠️
+
+
No Popularity Data
+
+ You haven't imported chat reactions yet. Import now to track which games your players loved!
+
+
{ 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
+
+
+
+
+ )}
+
+
+
+ Session Notes (optional)
+
+
+
+
+ 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
+
+
+ Cancel
+
+
+
+
+ );
+}
+
+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 (
+
+
Import Chat Log
+
+ Upload a JSON file or paste JSON array with format: [{"username": "...", "message": "...", "timestamp": "..."}]
+
+ The system will detect "thisgame++" and "thisgame--" patterns and update game popularity.
+
+
+ Upload JSON File
+
+
+
— or —
+
+ Paste Chat JSON Data
+
+ {result && (
+
+
Import Successful!
+
+ Imported {result.messagesImported} messages, processed {result.votesProcessed} votes
+
+
+ )}
+
+
+ {importing ? 'Importing...' : 'Import'}
+
+
+ Close
+
+
+
+ );
+}
+
+export default SessionDetail;