finished many ui changes + polishing dopamine hits

This commit is contained in:
2026-07-11 12:27:35 -04:00
parent 4b89a3b034
commit a8c41a6852
15 changed files with 2952 additions and 554 deletions
+403 -25
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate, useLocation, Navigate } from 'react-router-dom';
const VALID_GRADES = new Set(['G3', 'G6', 'G9']);
@@ -9,21 +9,380 @@ function authHeaders() {
return token ? { Authorization: `Bearer ${token}` } : {};
}
function FindingScreen({ elapsed, onBack }) {
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('');
useEffect(() => { const t = setInterval(() => setDots(d => d.length >= 3 ? '' : d + '.'), 400); return () => clearInterval(t); }, []);
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', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#fff', fontFamily: 'system-ui,sans-serif', gap: 24 }}>
<div style={{ width: 64, height: 64, borderRadius: '50%', border: '4px solid #e0e0e0', borderTopColor: '#1a73e8', animation: 'spin 0.8s linear infinite' }} />
<h2 style={{ fontSize: 22, fontWeight: 600, color: '#333', margin: 0 }}>Searching for opponent{dots}</h2>
<p style={{ fontSize: 15, color: '#888', margin: 0 }}>Searching all players...</p>
<div style={{ fontSize: 14, color: '#666', fontFamily: 'system-ui,sans-serif', fontWeight: 500, letterSpacing: '0.02em' }}>
Time elapsed: {minutes}:{String(seconds).padStart(2, '0')}
<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>
<button onClick={onBack} style={{ marginTop: 8, padding: '10px 32px', fontSize: 15, fontWeight: 600, background: '#f5f5f5', color: '#555', border: '1px solid #ccc', borderRadius: 999, cursor: 'pointer', fontFamily: 'system-ui,sans-serif' }}> Back to Dashboard</button>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
);
}
@@ -33,9 +392,10 @@ export default function Queue() {
const { state } = useLocation();
const grade = state?.grade ?? null;
const [myName, setMyName] = useState('');
const [myAvatar, setMyAvatar] = useState(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);
@@ -43,11 +403,12 @@ export default function Queue() {
const leaveTimerRef = useRef(null);
const leavingRef = useRef(false);
// Elapsed timer
// 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(() => {
@@ -115,7 +476,7 @@ export default function Queue() {
if (!data || leavingRef.current) return;
if (data.status === 'matched') {
clearInterval(pollRef.current);
navigateToGame(data);
showMatchup(data);
} else if (data.status === 'not_in_queue') {
clearInterval(pollRef.current);
fetch(`${API}/queue/join`, {
@@ -130,7 +491,7 @@ export default function Queue() {
.then(d => {
if (!d || leavingRef.current) return;
if (d.status === 'waiting') pollQueue();
else if (d.status === 'matched') navigateToGame(d);
else if (d.status === 'matched') showMatchup(d);
})
.catch(() => {});
}
@@ -144,29 +505,46 @@ export default function Queue() {
.then(r => r.json())
.then(data => {
if (!leavingRef.current && data.status === 'matched') {
navigateToGame(data);
showMatchup(data);
}
})
.catch(() => {});
}
function navigateToGame(data) {
function showMatchup(data) {
clearInterval(pollRef.current);
completedRef.current = true;
setMatchData(data);
}
const goToGame = useCallback(() => {
navigate('/ranked-game', {
state: {
gameId: data.gameId,
questions: data.questions,
opponentName: data.opponent.username ?? data.opponent.displayName,
opponentAvatar: data.opponent.avatarUrl,
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 />;
return <FindingScreen elapsed={queueElapsed} onBack={() => navigate('/dashboard')} />;
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')} />;
}