Files
jackboxpartypack-gamepicker/frontend/src/pages/History.jsx
cottongin 85c06ff258 fix: session count label distinguishes visible vs total
Show "X visible (Y total)" when the history list is filtered or limited,
and "X sessions total" only when every session is actually displayed.

Made-with: Cursor
2026-03-23 10:41:38 -04:00

430 lines
17 KiB
JavaScript

import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useToast } from '../components/Toast';
import api from '../api/axios';
import { formatLocalDate, isSunday } from '../utils/dateUtils';
import { prefixKey } from '../utils/adminPrefs';
function History() {
const { isAuthenticated, adminName } = useAuth();
const { error, success } = useToast();
const navigate = useNavigate();
const [sessions, setSessions] = useState([]);
const [loading, setLoading] = useState(true);
const [totalCount, setTotalCount] = useState(0);
const [absoluteTotal, setAbsoluteTotal] = useState(0);
const [closingSession, setClosingSession] = useState(null);
const [filter, setFilter] = useState(() => localStorage.getItem(prefixKey(adminName, 'history-filter')) || 'default');
const [limit, setLimit] = useState(() => localStorage.getItem(prefixKey(adminName, 'history-show-limit')) || '5');
const [selectMode, setSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState(new Set());
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
const longPressTimer = useRef(null);
const longPressFired = useRef(false);
const loadSessions = useCallback(async () => {
try {
const response = await api.get('/sessions', {
params: { filter, limit }
});
setSessions(response.data);
setTotalCount(parseInt(response.headers['x-total-count'] || '0', 10));
setAbsoluteTotal(parseInt(response.headers['x-absolute-total'] || '0', 10));
} catch (err) {
console.error('Failed to load sessions', err);
} finally {
setLoading(false);
}
}, [filter, limit]);
useEffect(() => {
loadSessions();
}, [loadSessions]);
useEffect(() => {
const interval = setInterval(() => {
loadSessions();
}, 3000);
return () => clearInterval(interval);
}, [loadSessions]);
useEffect(() => {
if (adminName) {
const savedFilter = localStorage.getItem(prefixKey(adminName, 'history-filter'));
const savedLimit = localStorage.getItem(prefixKey(adminName, 'history-show-limit'));
if (savedFilter) setFilter(savedFilter);
if (savedLimit) setLimit(savedLimit);
}
}, [adminName]);
const handleFilterChange = (newFilter) => {
setFilter(newFilter);
localStorage.setItem(prefixKey(adminName, 'history-filter'), newFilter);
setSelectedIds(new Set());
};
const handleLimitChange = (newLimit) => {
setLimit(newLimit);
localStorage.setItem(prefixKey(adminName, 'history-show-limit'), newLimit);
setSelectedIds(new Set());
};
const handleCloseSession = async (sessionId, notes) => {
try {
await api.post(`/sessions/${sessionId}/close`, { notes });
await loadSessions();
setClosingSession(null);
success('Session ended successfully');
} catch (err) {
error('Failed to close session');
}
};
// Multi-select handlers
const toggleSelection = (sessionId) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(sessionId)) {
next.delete(sessionId);
} else {
next.add(sessionId);
}
return next;
});
};
const exitSelectMode = () => {
setSelectMode(false);
setSelectedIds(new Set());
setShowBulkDeleteConfirm(false);
};
const handlePointerDown = (sessionId) => {
if (!isAuthenticated || selectMode) return;
longPressFired.current = false;
longPressTimer.current = setTimeout(() => {
longPressFired.current = true;
setSelectMode(true);
setSelectedIds(new Set([sessionId]));
}, 500);
};
const handlePointerUp = () => {
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
};
const handleBulkAction = async (action) => {
try {
await api.post('/sessions/bulk', {
action,
ids: Array.from(selectedIds)
});
success(`${selectedIds.size} session${selectedIds.size !== 1 ? 's' : ''} ${action}d`);
setSelectedIds(new Set());
setShowBulkDeleteConfirm(false);
await loadSessions();
} catch (err) {
error(err.response?.data?.error || `Failed to ${action} sessions`);
}
};
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-2xl mx-auto">
<h1 className="text-4xl font-bold mb-8 text-gray-800 dark:text-gray-100">Session History</h1>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
{/* Controls Bar */}
<div className="flex flex-wrap justify-between items-center gap-3 mb-4">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Filter:</span>
<select
value={filter}
onChange={(e) => handleFilterChange(e.target.value)}
className="px-2 py-1.5 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 cursor-pointer"
>
<option value="default">Sessions</option>
<option value="archived">Archived</option>
<option value="all">All</option>
</select>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Show:</span>
<select
value={limit}
onChange={(e) => handleLimitChange(e.target.value)}
className="px-2 py-1.5 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 cursor-pointer"
>
<option value="5">5</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="all">All</option>
</select>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-400 dark:text-gray-500">
{sessions.length === absoluteTotal
? `${absoluteTotal} session${absoluteTotal !== 1 ? 's' : ''} total`
: `${sessions.length} visible (${absoluteTotal} total)`
}
</span>
{isAuthenticated && (
<button
onClick={selectMode ? exitSelectMode : () => setSelectMode(true)}
className={`px-3 py-1.5 rounded text-sm font-medium transition ${
selectMode
? 'bg-indigo-600 dark:bg-indigo-700 text-white hover:bg-indigo-700 dark:hover:bg-indigo-800'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
{selectMode ? '✓ Done' : 'Select'}
</button>
)}
</div>
</div>
{/* Session List */}
{sessions.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400">No sessions found</p>
) : (
<div className="space-y-2">
{sessions.map(session => {
const isActive = session.is_active === 1;
const isSelected = selectedIds.has(session.id);
const isSundaySession = isSunday(session.created_at);
const isArchived = session.archived === 1;
const canSelect = selectMode && !isActive;
return (
<div
key={session.id}
className={`border rounded-lg transition ${
selectMode && isActive
? 'opacity-50 cursor-not-allowed border-gray-300 dark:border-gray-600'
: isSelected
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20 cursor-pointer'
: 'border-gray-300 dark:border-gray-600 hover:border-indigo-400 dark:hover:border-indigo-500 cursor-pointer'
}`}
onClick={() => {
if (longPressFired.current) {
longPressFired.current = false;
return;
}
if (selectMode) {
if (!isActive) toggleSelection(session.id);
} else {
navigate(`/history/${session.id}`);
}
}}
onPointerDown={() => {
if (!isActive) handlePointerDown(session.id);
}}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
>
<div className="p-4">
<div className="flex items-start gap-3">
{selectMode && (
<div className={`mt-0.5 w-5 h-5 flex-shrink-0 rounded border-2 flex items-center justify-center ${
isActive
? 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700'
: isSelected
? 'border-indigo-600 bg-indigo-600'
: 'border-gray-300 dark:border-gray-600'
}`}>
{isSelected && (
<span className="text-white text-xs font-bold"></span>
)}
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center mb-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-gray-800 dark:text-gray-100">
Session #{session.id}
</span>
{isActive && (
<span className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs px-2 py-0.5 rounded">
Active
</span>
)}
{isSundaySession && (
<span className="bg-amber-100 dark:bg-amber-900 text-amber-800 dark:text-amber-200 text-xs px-2 py-0.5 rounded font-semibold">
🎲 Game Night
</span>
)}
{isArchived && (filter === 'all' || filter === 'archived') && (
<span className="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 text-xs px-2 py-0.5 rounded">
Archived
</span>
)}
</div>
<span className="text-sm text-gray-500 dark:text-gray-400">
{session.games_played} game{session.games_played !== 1 ? 's' : ''}
</span>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{formatLocalDate(session.created_at)}
{isSundaySession && (
<span className="text-gray-400 dark:text-gray-500"> · Sunday</span>
)}
</div>
{session.has_notes && session.notes_preview && (
<div className="mt-2 text-sm text-indigo-400 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/20 px-3 py-2 rounded border-l-2 border-indigo-500">
{session.notes_preview}
</div>
)}
</div>
</div>
</div>
{!selectMode && isAuthenticated && isActive && (
<div className="px-4 pb-4 pt-0">
<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>
</div>
)}
</div>
);
})}
</div>
)}
{/* Multi-select Action Bar */}
{selectMode && selectedIds.size > 0 && (
<div className="sticky bottom-4 mt-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 flex justify-between items-center">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
{selectedIds.size} selected
</span>
<div className="flex gap-2">
{filter !== 'archived' && (
<button
onClick={() => handleBulkAction('archive')}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm hover:bg-indigo-700 transition"
>
Archive
</button>
)}
{filter !== 'default' && (
<button
onClick={() => handleBulkAction('unarchive')}
className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm hover:bg-green-700 transition"
>
Unarchive
</button>
)}
<button
onClick={() => setShowBulkDeleteConfirm(true)}
className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm hover:bg-red-700 transition"
>
Delete
</button>
</div>
</div>
)}
</div>
{/* End Session Modal */}
{closingSession && (
<EndSessionModal
sessionId={closingSession}
onClose={() => setClosingSession(null)}
onConfirm={handleCloseSession}
/>
)}
{/* Bulk Delete Confirmation Modal */}
{showBulkDeleteConfirm && (
<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 {selectedIds.size} Session{selectedIds.size !== 1 ? 's' : ''}?
</h2>
<p className="text-gray-700 dark:text-gray-300 mb-6">
This will permanently delete {selectedIds.size} session{selectedIds.size !== 1 ? 's' : ''} and all associated games and chat logs. This action cannot be undone.
</p>
<div className="flex gap-4">
<button
onClick={() => handleBulkAction('delete')}
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={() => setShowBulkDeleteConfirm(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
</button>
</div>
</div>
</div>
)}
</div>
);
}
function EndSessionModal({ 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 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>
<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>
);
}
export default History;