Spaces:
Runtime error
Runtime error
| import { useState, useEffect, useRef, useCallback } from 'react'; | |
| import { useNavigate } from 'react-router-dom'; | |
| import { Box, Button, Typography, useMediaQuery, useTheme } from '@mui/material'; | |
| import ProgressBar from '../components/ProgressBar'; | |
| import SwipeCard from '../components/SwipeCard'; | |
| export default function Trial() { | |
| const navigate = useNavigate(); | |
| const theme = useTheme(); | |
| const isDesktop = useMediaQuery(theme.breakpoints.up('md')); | |
| const [trials, setTrials] = useState([]); | |
| const [currentIndex, setCurrentIndex] = useState(0); | |
| const [sessionId, setSessionId] = useState(''); | |
| const [imageLoaded, setImageLoaded] = useState(false); | |
| const imageShownAt = useRef(Date.now()); | |
| // Load session from localStorage | |
| useEffect(() => { | |
| const sid = localStorage.getItem('session_id'); | |
| const trialsJson = localStorage.getItem('trials'); | |
| const idx = parseInt(localStorage.getItem('current_index') || '0', 10); | |
| if (!sid || !trialsJson) { | |
| navigate('/'); | |
| return; | |
| } | |
| const parsedTrials = JSON.parse(trialsJson); | |
| setSessionId(sid); | |
| setTrials(parsedTrials); | |
| setCurrentIndex(idx); | |
| }, [navigate]); | |
| // Reset timer when trial changes | |
| useEffect(() => { | |
| imageShownAt.current = Date.now(); | |
| setImageLoaded(false); | |
| }, [currentIndex]); | |
| // Preload next 2 images | |
| useEffect(() => { | |
| if (trials.length === 0) return; | |
| for (let offset = 1; offset <= 2; offset++) { | |
| const nextIdx = currentIndex + offset; | |
| if (nextIdx < trials.length) { | |
| const img = new Image(); | |
| img.src = `/images/${trials[nextIdx].path}`; | |
| } | |
| } | |
| }, [currentIndex, trials]); | |
| const handleResponse = useCallback((response) => { | |
| if (trials.length === 0 || currentIndex >= trials.length) return; | |
| const trial = trials[currentIndex]; | |
| const responseTimeMs = Date.now() - imageShownAt.current; | |
| // Fire and forget | |
| fetch('/api/session/respond', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| session_id: sessionId, | |
| trial_index: currentIndex, | |
| image_id: trial.id, | |
| label: trial.label, | |
| response, | |
| response_time_ms: responseTimeMs, | |
| }), | |
| }).catch(console.error); | |
| const nextIndex = currentIndex + 1; | |
| if (nextIndex >= trials.length) { | |
| // Complete session | |
| fetch('/api/session/complete', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ session_id: sessionId }), | |
| }).catch(console.error); | |
| localStorage.setItem('current_index', String(nextIndex)); | |
| navigate('/done'); | |
| } else { | |
| setCurrentIndex(nextIndex); | |
| localStorage.setItem('current_index', String(nextIndex)); | |
| } | |
| }, [trials, currentIndex, sessionId, navigate]); | |
| // Keyboard shortcuts | |
| useEffect(() => { | |
| const handleKey = (e) => { | |
| if (e.key === 'ArrowLeft') handleResponse('real'); | |
| else if (e.key === 'ArrowRight') handleResponse('fake'); | |
| }; | |
| window.addEventListener('keydown', handleKey); | |
| return () => window.removeEventListener('keydown', handleKey); | |
| }, [handleResponse]); | |
| if (trials.length === 0) return null; | |
| if (currentIndex >= trials.length) return null; | |
| const currentTrial = trials[currentIndex]; | |
| return ( | |
| <Box | |
| sx={{ | |
| height: '100vh', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| overflow: 'hidden', | |
| }} | |
| > | |
| {/* Top bar */} | |
| <Box sx={{ px: 2, pt: 1 }}> | |
| <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}> | |
| <Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}> | |
| Color Turing Test | |
| </Typography> | |
| <Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}> | |
| {currentIndex + 1} / {trials.length} | |
| </Typography> | |
| </Box> | |
| <ProgressBar current={currentIndex + 1} total={trials.length} /> | |
| </Box> | |
| {/* Main image area */} | |
| <SwipeCard | |
| onSwipeLeft={() => handleResponse('real')} | |
| onSwipeRight={() => handleResponse('fake')} | |
| > | |
| <Box | |
| sx={{ | |
| flex: 1, | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| p: 2, | |
| minHeight: 0, | |
| }} | |
| > | |
| {/* Fixed-size container — all images render at the same visual footprint */} | |
| <Box | |
| sx={{ | |
| width: { xs: 'min(90vw, 90vh - 200px)', md: 'min(60vw, calc(100vh - 240px))' }, | |
| height: { xs: 'min(90vw, 90vh - 200px)', md: 'min(60vw, calc(100vh - 240px))' }, | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| borderRadius: 2, | |
| overflow: 'hidden', | |
| bgcolor: 'grey.900', | |
| }} | |
| > | |
| <Box | |
| component="img" | |
| key={currentTrial.path} | |
| src={`/images/${currentTrial.path}`} | |
| alt="Test image" | |
| onLoad={() => { | |
| setImageLoaded(true); | |
| imageShownAt.current = Date.now(); | |
| }} | |
| sx={{ | |
| width: '100%', | |
| height: '100%', | |
| objectFit: 'contain', | |
| opacity: imageLoaded ? 1 : 0, | |
| transition: 'opacity 0.15s ease-in', | |
| }} | |
| /> | |
| </Box> | |
| </Box> | |
| </SwipeCard> | |
| {/* Bottom area */} | |
| <Box sx={{ p: 2, pb: 3 }}> | |
| {/* Mobile: swipe hint + smaller tap buttons as fallback */} | |
| {!isDesktop && ( | |
| <Typography | |
| variant="caption" | |
| color="text.secondary" | |
| textAlign="center" | |
| display="block" | |
| sx={{ mb: 1.5, opacity: 0.5, fontSize: '0.72rem', letterSpacing: 1 }} | |
| > | |
| absent ← swipe → present | |
| </Typography> | |
| )} | |
| <Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}> | |
| <Button | |
| variant="outlined" | |
| color="error" | |
| size={isDesktop ? 'large' : 'medium'} | |
| onClick={() => handleResponse('real')} | |
| sx={{ | |
| flex: 1, | |
| maxWidth: 240, | |
| fontSize: isDesktop ? '1.1rem' : '0.95rem', | |
| py: isDesktop ? 1.5 : 1, | |
| borderWidth: 2, | |
| '&:hover': { borderWidth: 2 }, | |
| }} | |
| > | |
| ✗ ABSENT | |
| </Button> | |
| <Button | |
| variant="outlined" | |
| color="success" | |
| size={isDesktop ? 'large' : 'medium'} | |
| onClick={() => handleResponse('fake')} | |
| sx={{ | |
| flex: 1, | |
| maxWidth: 240, | |
| fontSize: isDesktop ? '1.1rem' : '0.95rem', | |
| py: isDesktop ? 1.5 : 1, | |
| borderWidth: 2, | |
| '&:hover': { borderWidth: 2 }, | |
| }} | |
| > | |
| ✓ PRESENT | |
| </Button> | |
| </Box> | |
| {/* Desktop: keyboard hint */} | |
| {isDesktop && ( | |
| <Typography | |
| variant="caption" | |
| color="text.secondary" | |
| textAlign="center" | |
| display="block" | |
| sx={{ mt: 1.5, opacity: 0.5, fontSize: '0.7rem' }} | |
| > | |
| ← Absent Present → | |
| </Typography> | |
| )} | |
| </Box> | |
| </Box> | |
| ); | |
| } | |