feat: rewrite History page with controls bar, multi-select, Sunday badge, and update SessionDetail with archive support

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-23 02:21:37 -04:00
parent bbd2e51567
commit d613d4e507
2 changed files with 336 additions and 62 deletions

View File

@@ -1,29 +1,42 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useToast } from '../components/Toast'; import { useToast } from '../components/Toast';
import api from '../api/axios'; import api from '../api/axios';
import { formatLocalDate } from '../utils/dateUtils'; import { formatLocalDate, isSunday } from '../utils/dateUtils';
function History() { function History() {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const { error, success } = useToast(); const { error, success } = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
const [sessions, setSessions] = useState([]); const [sessions, setSessions] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [totalCount, setTotalCount] = useState(0);
const [closingSession, setClosingSession] = useState(null); const [closingSession, setClosingSession] = useState(null);
const [showAllSessions, setShowAllSessions] = useState(false);
const [filter, setFilter] = useState(() => localStorage.getItem('history-filter') || 'default');
const [limit, setLimit] = useState(() => localStorage.getItem('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 loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {
try { try {
const response = await api.get('/sessions'); const response = await api.get('/sessions', {
params: { filter, limit }
});
setSessions(response.data); setSessions(response.data);
setTotalCount(parseInt(response.headers['x-total-count'] || '0', 10));
} catch (err) { } catch (err) {
console.error('Failed to load sessions', err); console.error('Failed to load sessions', err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [filter, limit]);
useEffect(() => { useEffect(() => {
loadSessions(); loadSessions();
@@ -36,6 +49,18 @@ function History() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [loadSessions]); }, [loadSessions]);
const handleFilterChange = (newFilter) => {
setFilter(newFilter);
localStorage.setItem('history-filter', newFilter);
setSelectedIds(new Set());
};
const handleLimitChange = (newLimit) => {
setLimit(newLimit);
localStorage.setItem('history-show-limit', newLimit);
setSelectedIds(new Set());
};
const handleCloseSession = async (sessionId, notes) => { const handleCloseSession = async (sessionId, notes) => {
try { try {
await api.post(`/sessions/${sessionId}/close`, { notes }); await api.post(`/sessions/${sessionId}/close`, { notes });
@@ -47,6 +72,55 @@ function History() {
} }
}; };
// 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;
longPressTimer.current = setTimeout(() => {
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) { if (loading) {
return ( return (
<div className="flex justify-center items-center h-64"> <div className="flex justify-center items-center h-64">
@@ -60,41 +134,126 @@ function History() {
<h1 className="text-4xl font-bold mb-8 text-gray-800 dark:text-gray-100">Session History</h1> <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"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<div className="flex justify-between items-center mb-4"> {/* Controls Bar */}
<h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-100">Sessions</h2> <div className="flex flex-wrap justify-between items-center gap-3 mb-4">
{sessions.length > 5 && ( <div className="flex items-center gap-3">
<button <div className="flex items-center gap-1.5">
onClick={() => setShowAllSessions(!showAllSessions)} <span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Filter:</span>
className="text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 transition" <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"
> >
{showAllSessions ? 'Show Recent' : `Show All (${sessions.length})`} <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">
{totalCount} session{totalCount !== 1 ? 's' : ''} 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> </button>
)} )}
</div> </div>
</div>
{/* Session List */}
{sessions.length === 0 ? ( {sessions.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400">No sessions found</p> <p className="text-gray-500 dark:text-gray-400">No sessions found</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{(showAllSessions ? sessions : sessions.slice(0, 5)).map(session => ( {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 <div
key={session.id} key={session.id}
className="border border-gray-300 dark:border-gray-600 rounded-lg hover:border-indigo-400 dark:hover:border-indigo-500 transition cursor-pointer" className={`border rounded-lg transition ${
> selectMode && isActive
<div ? 'opacity-50 cursor-not-allowed border-gray-300 dark:border-gray-600'
onClick={() => navigate(`/history/${session.id}`)} : isSelected
className="p-4" ? '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 (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 justify-between items-center mb-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-gray-800 dark:text-gray-100"> <span className="font-semibold text-gray-800 dark:text-gray-100">
Session #{session.id} Session #{session.id}
</span> </span>
{session.is_active === 1 && ( {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"> <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 Active
</span> </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> </div>
<span className="text-sm text-gray-500 dark:text-gray-400"> <span className="text-sm text-gray-500 dark:text-gray-400">
{session.games_played} game{session.games_played !== 1 ? 's' : ''} {session.games_played} game{session.games_played !== 1 ? 's' : ''}
@@ -102,6 +261,9 @@ function History() {
</div> </div>
<div className="text-sm text-gray-500 dark:text-gray-400"> <div className="text-sm text-gray-500 dark:text-gray-400">
{formatLocalDate(session.created_at)} {formatLocalDate(session.created_at)}
{isSundaySession && (
<span className="text-gray-400 dark:text-gray-500"> · Sunday</span>
)}
</div> </div>
{session.has_notes && session.notes_preview && ( {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"> <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">
@@ -109,8 +271,10 @@ function History() {
</div> </div>
)} )}
</div> </div>
</div>
</div>
{isAuthenticated && session.is_active === 1 && ( {!selectMode && isAuthenticated && isActive && (
<div className="px-4 pb-4 pt-0"> <div className="px-4 pb-4 pt-0">
<button <button
onClick={(e) => { onClick={(e) => {
@@ -124,11 +288,46 @@ function History() {
</div> </div>
)} )}
</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>
)} )}
</div> </div>
{/* End Session Modal */}
{closingSession && ( {closingSession && (
<EndSessionModal <EndSessionModal
sessionId={closingSession} sessionId={closingSession}
@@ -136,6 +335,34 @@ function History() {
onConfirm={handleCloseSession} 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> </div>
); );
} }

View File

@@ -4,7 +4,7 @@ import Markdown from 'react-markdown';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useToast } from '../components/Toast'; import { useToast } from '../components/Toast';
import api from '../api/axios'; import api from '../api/axios';
import { formatLocalDateTime, formatLocalTime } from '../utils/dateUtils'; import { formatLocalDateTime, formatLocalTime, isSunday } from '../utils/dateUtils';
import PopularityBadge from '../components/PopularityBadge'; import PopularityBadge from '../components/PopularityBadge';
function SessionDetail() { function SessionDetail() {
@@ -94,6 +94,17 @@ function SessionDetail() {
} }
}; };
const handleArchive = async () => {
const action = session.archived === 1 ? 'unarchive' : 'archive';
try {
await api.post(`/sessions/${id}/${action}`);
await loadSession();
success(`Session ${action}d`);
} catch (err) {
showError(err.response?.data?.error || `Failed to ${action} session`);
}
};
const handleCloseSession = async (sessionId, notes) => { const handleCloseSession = async (sessionId, notes) => {
try { try {
await api.post(`/sessions/${sessionId}/close`, { notes }); await api.post(`/sessions/${sessionId}/close`, { notes });
@@ -155,6 +166,22 @@ function SessionDetail() {
Back to History Back to History
</Link> </Link>
{session.archived === 1 && (
<div className="bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg p-4 mb-4 flex justify-between items-center">
<span className="text-gray-600 dark:text-gray-400 text-sm font-medium">
This session is archived
</span>
{isAuthenticated && (
<button
onClick={handleArchive}
className="text-sm bg-green-600 text-white px-3 py-1.5 rounded hover:bg-green-700 transition"
>
Unarchive
</button>
)}
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-6">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-4"> <div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-4">
<div> <div>
@@ -167,9 +194,17 @@ function SessionDetail() {
🟢 Active 🟢 Active
</span> </span>
)} )}
{isSunday(session.created_at) && (
<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>
)}
</div> </div>
<p className="text-gray-600 dark:text-gray-400"> <p className="text-gray-600 dark:text-gray-400">
{formatLocalDateTime(session.created_at)} {formatLocalDateTime(session.created_at)}
{isSunday(session.created_at) && (
<span className="text-gray-400 dark:text-gray-500"> · Sunday</span>
)}
{' • '} {' • '}
{session.games_played} game{session.games_played !== 1 ? 's' : ''} played {session.games_played} game{session.games_played !== 1 ? 's' : ''} played
</p> </p>
@@ -209,12 +244,24 @@ function SessionDetail() {
</> </>
)} )}
{isAuthenticated && session.is_active === 0 && ( {isAuthenticated && session.is_active === 0 && (
<>
<button
onClick={handleArchive}
className={`${
session.archived === 1
? 'bg-green-600 dark:bg-green-700 hover:bg-green-700 dark:hover:bg-green-800'
: 'bg-gray-500 dark:bg-gray-600 hover:bg-gray-600 dark:hover:bg-gray-700'
} text-white px-4 py-2 rounded-lg transition text-sm`}
>
{session.archived === 1 ? 'Unarchive' : 'Archive'}
</button>
<button <button
onClick={() => setShowDeleteSessionConfirm(true)} onClick={() => 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" 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 Delete Session
</button> </button>
</>
)} )}
</div> </div>
</div> </div>