Files
ranked-eqao/frontend/src/pages/Queue.jsx
T

551 lines
21 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate, useLocation, Navigate } from 'react-router-dom';
const VALID_GRADES = new Set(['G3', 'G6', 'G9']);
const API = import.meta.env.VITE_API_URL;
function authHeaders() {
const token = sessionStorage.getItem('ranked_token');
return token ? { Authorization: `Bearer ${token}` } : {};
}
function getNameFromToken() {
try {
const token = sessionStorage.getItem('ranked_token');
if (!token) return 'You';
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.name ?? 'You';
} catch {
return 'You';
}
}
function getAvatarFromToken() {
try {
const token = sessionStorage.getItem('ranked_token');
if (!token) return null;
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.picture ?? null;
} catch {
return null;
}
}
function MatchFoundScreen({ myName, myAvatar, opponentName, opponentAvatar, grade, onDone }) {
const [count, setCount] = useState(5);
const [phase, setPhase] = useState('intro'); // intro → reveal → countdown
useEffect(() => {
const t1 = setTimeout(() => setPhase('reveal'), 400);
const t2 = setTimeout(() => setPhase('countdown'), 1200);
return () => { clearTimeout(t1); clearTimeout(t2); };
}, []);
useEffect(() => {
if (phase !== 'countdown') return;
if (count <= 0) { onDone(); return; }
const id = setTimeout(() => setCount(c => c - 1), 1000);
return () => clearTimeout(id);
}, [count, phase, onDone]);
const countColor = count <= 1 ? '#ff3b30' : count <= 2 ? '#ff9500' : count <= 3 ? '#ffcc00' : '#4fc3f7';
const countGlow = count <= 1 ? '#ff3b3088' : count <= 2 ? '#ff950088' : count <= 3 ? '#ffcc0088' : '#4fc3f788';
return (
<div style={{
minHeight: '100vh', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
background: '#07090f',
fontFamily: "'system-ui',sans-serif", overflow: 'hidden', position: 'relative',
}}>
<style>{`
@keyframes bg-scan {
0% { transform: translateY(-100%); opacity: 0.07; }
100% { transform: translateY(100vh); opacity: 0.03; }
}
@keyframes header-drop {
0% { transform: translateY(-40px) scaleX(0.6); opacity: 0; letter-spacing: 0.3em; }
60% { transform: translateY(4px) scaleX(1.02); opacity: 1; letter-spacing: 0.18em; }
100% { transform: translateY(0) scaleX(1); opacity: 1; letter-spacing: 0.18em; }
}
@keyframes player-slide-left {
0% { transform: translateX(-120px); opacity: 0; }
70% { transform: translateX(6px); opacity: 1; }
100% { transform: translateX(0); opacity: 1; }
}
@keyframes player-slide-right {
0% { transform: translateX(120px); opacity: 0; }
70% { transform: translateX(-6px); opacity: 1; }
100% { transform: translateX(0); opacity: 1; }
}
@keyframes vs-slam {
0% { transform: scale(3) rotate(-8deg); opacity: 0; }
55% { transform: scale(0.92) rotate(1deg); opacity: 1; }
75% { transform: scale(1.06) rotate(-0.5deg); }
100% { transform: scale(1) rotate(0deg); opacity: 1; }
}
@keyframes divider-grow {
0% { width: 0; opacity: 0; }
100% { width: 100%; opacity: 1; }
}
@keyframes count-slam {
0% { transform: scale(2.2); opacity: 0; }
50% { transform: scale(0.88); opacity: 1; }
75% { transform: scale(1.08); }
100% { transform: scale(1); opacity: 1; }
}
@keyframes ring-pulse {
0% { transform: scale(1); opacity: 0.7; }
100% { transform: scale(2.2); opacity: 0; }
}
@keyframes avatar-glow-blue {
0%, 100% { box-shadow: 0 0 18px 4px #1a73e8aa, 0 0 0 3px #1a73e8; }
50% { box-shadow: 0 0 32px 10px #1a73e8cc, 0 0 0 3px #1a73e8; }
}
@keyframes avatar-glow-red {
0%, 100% { box-shadow: 0 0 18px 4px #ff3b30aa, 0 0 0 3px #ff3b30; }
50% { box-shadow: 0 0 32px 10px #ff3b30cc, 0 0 0 3px #ff3b30; }
}
@keyframes scan-line {
0% { top: 0%; }
100% { top: 100%; }
}
.player-left { animation: player-slide-left 0.55s cubic-bezier(.22,1,.36,1) 0.9s both; }
.player-right { animation: player-slide-right 0.55s cubic-bezier(.22,1,.36,1) 0.9s both; }
.vs-text { animation: vs-slam 0.5s cubic-bezier(.22,1,.36,1) 1.05s both; }
.header-text { animation: header-drop 0.6s cubic-bezier(.22,1,.36,1) 0.1s both; }
.count-slam { animation: count-slam 0.3s cubic-bezier(.22,1,.36,1) both; }
.ring { animation: ring-pulse 0.7s ease-out both; }
`}</style>
{/* Scanline overlay */}
<div style={{
position: 'absolute', inset: 0, pointerEvents: 'none', overflow: 'hidden', zIndex: 0,
}}>
<div style={{
position: 'absolute', left: 0, right: 0, height: 2,
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.04), transparent)',
animation: 'scan-line 3s linear infinite',
}} />
{/* subtle grid */}
<div style={{
position: 'absolute', inset: 0,
backgroundImage: 'linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px)',
backgroundSize: '40px 40px',
}} />
</div>
{/* Header */}
<div className="header-text" style={{
fontSize: 13, fontWeight: 800, letterSpacing: '0.18em',
textTransform: 'uppercase', color: '#4fc3f7',
marginBottom: 48, zIndex: 1,
textShadow: '0 0 20px #4fc3f7aa',
}}>
Match Found
</div>
{/* Players row */}
<div style={{ display: 'flex', alignItems: 'center', gap: 48, zIndex: 1 }}>
{/* You */}
<div className="player-left" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
<div style={{
width: 100, height: 100, borderRadius: '50%',
background: myAvatar ? 'transparent' : '#0d2a4a',
overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'avatar-glow-blue 2s ease-in-out infinite',
}}>
{myAvatar
? <img src={myAvatar} alt={myName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: <span style={{ fontSize: 38, color: '#4fc3f7', fontWeight: 800 }}>{(myName?.[0] ?? '?').toUpperCase()}</span>
}
</div>
<span style={{ fontSize: 15, fontWeight: 700, color: '#e8f4fd', maxWidth: 130, textAlign: 'center', wordBreak: 'break-word', letterSpacing: '0.01em' }}>{myName}</span>
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: '#1a73e8' }}>You</span>
</div>
{/* VS + countdown */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 20 }}>
<span className="vs-text" style={{
fontSize: 40, fontWeight: 900, color: '#fff',
letterSpacing: '0.05em',
textShadow: '0 0 24px rgba(255,255,255,0.25)',
}}>VS</span>
{phase === 'countdown' && (
<div style={{ position: 'relative', width: 80, height: 80, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{/* pulse rings */}
<div key={`r1-${count}`} className="ring" style={{
position: 'absolute', width: 80, height: 80, borderRadius: '50%',
border: `2px solid ${countColor}`,
}} />
<div key={`r2-${count}`} className="ring" style={{
position: 'absolute', width: 80, height: 80, borderRadius: '50%',
border: `2px solid ${countColor}`,
animationDelay: '0.15s',
}} />
<div key={count} className="count-slam" style={{
width: 80, height: 80, borderRadius: '50%',
background: `radial-gradient(circle at 38% 38%, ${countColor}33, #0a0a1a)`,
border: `2px solid ${countColor}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 38, fontWeight: 900, color: countColor,
boxShadow: `0 0 28px ${countGlow}, inset 0 0 12px ${countColor}22`,
fontVariantNumeric: 'tabular-nums',
}}>
{count}
</div>
</div>
)}
</div>
{/* Opponent */}
<div className="player-right" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
<div style={{
width: 100, height: 100, borderRadius: '50%',
background: opponentAvatar ? 'transparent' : '#2a0d0d',
overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'avatar-glow-red 2s ease-in-out infinite',
}}>
{opponentAvatar
? <img src={opponentAvatar} alt={opponentName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: <span style={{ fontSize: 38, color: '#ff6b6b', fontWeight: 800 }}>{(opponentName?.[0] ?? '?').toUpperCase()}</span>
}
</div>
<span style={{ fontSize: 15, fontWeight: 700, color: '#ffe8e8', maxWidth: 130, textAlign: 'center', wordBreak: 'break-word', letterSpacing: '0.01em' }}>{opponentName}</span>
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: '#ff3b30' }}>Opponent</span>
</div>
</div>
{/* Divider line */}
<div style={{
marginTop: 52, height: 1, zIndex: 1,
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.08), transparent)',
animation: 'divider-grow 0.6s ease-out 1.4s both',
width: 340,
}} />
{/* Grade tag */}
<div style={{
marginTop: 20, fontSize: 11, fontWeight: 700, letterSpacing: '0.15em',
textTransform: 'uppercase', color: 'rgba(255,255,255,0.2)', zIndex: 1,
animation: 'header-drop 0.5s ease-out 1.5s both',
}}>
Ranked · Grade {grade?.replace('G', '') ?? '?'}
</div>
</div>
);
}
const QUEUE_DIAMONDS = [
{ left: '8%', top: '15%', size: 10, dur: '12s', delay: '0s' },
{ left: '22%', top: '68%', size: 7, dur: '15s', delay: '2s' },
{ left: '52%', top: '20%', size: 13, dur: '11s', delay: '1s' },
{ left: '68%', top: '72%', size: 9, dur: '14s', delay: '3s' },
{ left: '84%', top: '35%', size: 11, dur: '13s', delay: '0.5s' },
{ left: '40%', top: '82%', size: 8, dur: '10s', delay: '4s' },
];
function FindingScreen({ elapsed, grade, onBack }) {
const [dots, setDots] = useState('');
const [playersOnline, setPlayersOnline] = useState(null);
useEffect(() => {
const t = setInterval(() => setDots(d => d.length >= 3 ? '' : d + '.'), 400);
return () => clearInterval(t);
}, []);
useEffect(() => {
const fetchCount = () => {
fetch(`${API}/online-count`)
.then(r => r.json())
.then(d => setPlayersOnline(d.online ?? 0))
.catch(() => {});
};
fetchCount();
const t = setInterval(fetchCount, 30000);
return () => clearInterval(t);
}, []);
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
const gradeNum = grade?.replace('G', '') ?? '?';
return (
<div style={{ minHeight: '100vh', position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', fontFamily: 'system-ui,sans-serif', overflow: 'hidden' }}>
<style>{`
@keyframes spinCW { to { transform: rotate(360deg); } }
@keyframes spinCCW { to { transform: rotate(-360deg); } }
`}</style>
{/* Arena background */}
<div className="arena-bg">
<div className="arena-grain" />
<div className="arena-shapes">
{QUEUE_DIAMONDS.map((d, i) => (
<div
key={i}
className="shape shape--diamond"
style={{
left: d.left, top: d.top,
width: d.size, height: d.size,
animationDuration: d.dur, animationDelay: d.delay,
opacity: 0.35,
}}
/>
))}
</div>
</div>
{/* Content */}
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 24 }}>
{/* Two counter-rotating rings */}
<div style={{ position: 'relative', width: 80, height: 80 }}>
<div style={{
position: 'absolute', inset: 0, borderRadius: '50%',
border: '3.5px solid rgba(245,197,24,0.12)',
borderTopColor: '#f5c518', borderRightColor: 'rgba(245,197,24,0.45)',
animation: 'spinCW 1.2s linear infinite',
}} />
<div style={{
position: 'absolute', inset: 13, borderRadius: '50%',
border: '3px solid rgba(79,195,247,0.12)',
borderTopColor: '#4fc3f7', borderLeftColor: 'rgba(79,195,247,0.45)',
animation: 'spinCCW 0.85s linear infinite',
}} />
<div style={{
position: 'absolute', top: '50%', left: '50%',
transform: 'translate(-50%,-50%)',
width: 7, height: 7, borderRadius: '50%',
background: '#f5c518',
boxShadow: '0 0 10px rgba(245,197,24,0.9)',
}} />
</div>
{/* Grade label */}
<p style={{
fontSize: 11, fontWeight: 700, letterSpacing: '0.22em',
textTransform: 'uppercase', color: 'rgba(245,197,24,0.45)',
margin: 0,
}}>
Grade {gradeNum} · Ranked
</p>
{/* Heading */}
<h2 style={{ fontSize: 22, fontWeight: 700, color: '#eceef8', margin: 0, letterSpacing: '0.01em' }}>
Searching for opponent{dots}
</h2>
{/* Players online */}
<p style={{
fontSize: 13, color: 'rgba(245,197,24,0.55)',
margin: 0, letterSpacing: '0.04em',
}}>
{playersOnline !== null ? `${playersOnline.toLocaleString()} ${playersOnline === 1 ? 'player' : 'players'} online` : 'finding players...'}
</p>
{/* Elapsed timer — large gold */}
<div style={{
fontSize: 48, fontWeight: 700,
color: '#f5c518', fontFamily: 'monospace',
letterSpacing: '0.06em', lineHeight: 1,
textShadow: '0 0 28px rgba(245,197,24,0.35)',
}}>
{String(minutes).padStart(2, '0')}:{String(seconds).padStart(2, '0')}
</div>
{/* Back button */}
<button
onClick={onBack}
style={{
marginTop: 8, padding: '10px 32px',
fontSize: 14, fontWeight: 600,
background: 'rgba(255,255,255,0.05)',
color: 'rgba(255,255,255,0.45)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 999, cursor: 'pointer',
fontFamily: 'system-ui,sans-serif',
transition: 'background 0.15s, border-color 0.15s, color 0.15s',
}}
onMouseEnter={e => {
e.currentTarget.style.background = 'rgba(255,255,255,0.09)';
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.25)';
e.currentTarget.style.color = 'rgba(255,255,255,0.75)';
}}
onMouseLeave={e => {
e.currentTarget.style.background = 'rgba(255,255,255,0.05)';
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)';
e.currentTarget.style.color = 'rgba(255,255,255,0.45)';
}}
>
Back to Dashboard
</button>
</div>
</div>
);
}
export default function Queue() {
const navigate = useNavigate();
const { state } = useLocation();
const grade = state?.grade ?? null;
const [myName, setMyName] = useState(() => getNameFromToken());
const [myAvatar, setMyAvatar] = useState(() => getAvatarFromToken());
const [queueElapsed, setQueueElapsed] = useState(0);
const [matchData, setMatchData] = useState(null);
const pollRef = useRef(null);
const joinedRef = useRef(false);
const completedRef = useRef(false);
const leaveTimerRef = useRef(null);
const leavingRef = useRef(false);
// Elapsed timer — stop once matched
useEffect(() => {
if (matchData) return;
const id = setInterval(() => setQueueElapsed(e => e + 1), 1000);
return () => clearInterval(id);
}, [matchData]);
// Fetch own user info
useEffect(() => {
fetch(`${API}/auth/me`, { headers: authHeaders() })
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data) return;
setMyName(data.username ?? data.displayName ?? 'You');
setMyAvatar(data.avatarUrl ?? null);
})
.catch(() => {});
}, []);
// Join queue on mount
useEffect(() => {
// Cancel any pending leave from a previous mount (handles StrictMode double-invoke)
if (leaveTimerRef.current) {
clearTimeout(leaveTimerRef.current);
leaveTimerRef.current = null;
}
// Reset leaving flag so callbacks work on this mount
leavingRef.current = false;
joinedRef.current = false;
completedRef.current = false;
fetch(`${API}/queue/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ grade }),
})
.then(r => {
if (r.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
return r.json();
})
.then(data => {
if (!data || leavingRef.current) return;
joinedRef.current = true;
if (data.status === 'waiting') {
pollQueue();
} else if (data.status === 'matched') {
fetchMatchData();
}
})
.catch(() => {});
return () => {
leavingRef.current = true;
clearInterval(pollRef.current);
if (joinedRef.current && !completedRef.current) {
leaveTimerRef.current = setTimeout(() => {
fetch(`${API}/queue/leave`, { method: 'DELETE', headers: authHeaders() }).catch(() => {});
}, 0);
}
};
}, [grade, navigate]);
function pollQueue() {
pollRef.current = setInterval(() => {
fetch(`${API}/queue/status`, { headers: authHeaders() })
.then(r => {
if (r.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
return r.json();
})
.then(data => {
if (!data || leavingRef.current) return;
if (data.status === 'matched') {
clearInterval(pollRef.current);
showMatchup(data);
} else if (data.status === 'not_in_queue') {
clearInterval(pollRef.current);
fetch(`${API}/queue/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ grade }),
})
.then(r => {
if (r.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
return r.json();
})
.then(d => {
if (!d || leavingRef.current) return;
if (d.status === 'waiting') pollQueue();
else if (d.status === 'matched') showMatchup(d);
})
.catch(() => {});
}
})
.catch(() => {});
}, 1000);
}
function fetchMatchData() {
fetch(`${API}/queue/status`, { headers: authHeaders() })
.then(r => r.json())
.then(data => {
if (!leavingRef.current && data.status === 'matched') {
showMatchup(data);
}
})
.catch(() => {});
}
function showMatchup(data) {
clearInterval(pollRef.current);
completedRef.current = true;
setMatchData(data);
}
const goToGame = useCallback(() => {
navigate('/ranked-game', {
state: {
gameId: matchData.gameId,
questions: matchData.questions,
opponentName: matchData.opponent.username ?? matchData.opponent.displayName,
opponentAvatar: matchData.opponent.avatarUrl,
myName,
myAvatar,
grade,
},
});
}, [matchData, myName, myAvatar, grade, navigate]);
if (!VALID_GRADES.has(grade)) return <Navigate to="/dashboard" replace />;
if (matchData) {
return (
<MatchFoundScreen
myName={myName}
myAvatar={myAvatar}
opponentName={matchData.opponent.username ?? matchData.opponent.displayName}
opponentAvatar={matchData.opponent.avatarUrl}
grade={grade}
onDone={goToGame}
/>
);
}
return <FindingScreen elapsed={queueElapsed} grade={grade} onBack={() => navigate('/dashboard')} />;
}