Compare commits
3 Commits
02ac5f9593
...
4b89a3b034
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b89a3b034 | |||
| 045048db20 | |||
| 1f99268588 |
@@ -4,7 +4,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Login from './pages/Login';
|
|||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import Game from './pages/Game';
|
import Game from './pages/Game';
|
||||||
import Queue from './pages/Queue';
|
import Queue from './pages/Queue';
|
||||||
|
import RankedGame from './pages/RankedGame';
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
|
|
||||||
function getValidStoredToken() {
|
function getValidStoredToken() {
|
||||||
@@ -38,6 +39,7 @@ export default function App() {
|
|||||||
<Route path="/dashboard" element={(loggedIn || hasUrlToken()) ? <Dashboard /> : <Navigate to="/" replace />} />
|
<Route path="/dashboard" element={(loggedIn || hasUrlToken()) ? <Dashboard /> : <Navigate to="/" replace />} />
|
||||||
<Route path="/game" element={(loggedIn || hasUrlToken()) ? <Game />: <Navigate to="/" replace />} />
|
<Route path="/game" element={(loggedIn || hasUrlToken()) ? <Game />: <Navigate to="/" replace />} />
|
||||||
<Route path="/queue" element={(loggedIn || hasUrlToken()) ? <Queue /> : <Navigate to="/" replace />} />
|
<Route path="/queue" element={(loggedIn || hasUrlToken()) ? <Queue /> : <Navigate to="/" replace />} />
|
||||||
|
<Route path="/ranked-game" element={(loggedIn || hasUrlToken()) ? <RankedGame /> : <Navigate to="/" replace />} />
|
||||||
<Route path="/settings" element={(loggedIn || hasUrlToken()) ? <Settings /> : <Navigate to="/" replace />} />
|
<Route path="/settings" element={(loggedIn || hasUrlToken()) ? <Settings /> : <Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const API = import.meta.env.VITE_API_URL;
|
||||||
|
const USERNAME_RE = /^[a-zA-Z0-9_]{3,20}$/;
|
||||||
|
|
||||||
|
export default function UsernameModal({ token, current, onClose, onSave }) {
|
||||||
|
const [value, setValue] = useState(current ?? '');
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
if (!USERNAME_RE.test(trimmed)) {
|
||||||
|
setError('3\u201320 characters, letters, numbers, and underscores only.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/me/username`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ username: trimmed }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) { onSave(data.username); onClose(); }
|
||||||
|
else if (data.error === 'taken') setError('Username already taken \u2014 try another.');
|
||||||
|
else setError('Something went wrong. Try again.');
|
||||||
|
} catch {
|
||||||
|
setError('Could not save. Check your connection.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="uname-overlay" onMouseDown={onClose}>
|
||||||
|
<div className="uname-modal" onMouseDown={e => e.stopPropagation()}>
|
||||||
|
<h3 className="uname-modal__title">Set username</h3>
|
||||||
|
<p className="uname-modal__hint">3–20 chars · letters, numbers, underscores</p>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="uname-input"
|
||||||
|
value={value}
|
||||||
|
onChange={e => { setValue(e.target.value); setError(null); }}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleSave()}
|
||||||
|
placeholder="e.g. player_one"
|
||||||
|
maxLength={20}
|
||||||
|
/>
|
||||||
|
{error && <p className="uname-error">{error}</p>}
|
||||||
|
<div className="uname-actions">
|
||||||
|
<button className="uname-btn uname-btn--cancel" onClick={onClose}>Cancel</button>
|
||||||
|
<button className="uname-btn uname-btn--save" onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? 'Saving\u2026' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+690
-44
@@ -20,9 +20,9 @@
|
|||||||
--silver: #94a3b8;
|
--silver: #94a3b8;
|
||||||
--platinum: #f5c518;
|
--platinum: #f5c518;
|
||||||
|
|
||||||
--text: #e8eaf6;
|
--text: #eceef8;
|
||||||
--text-muted: #4b5270;
|
--text-muted: #7a85a8;
|
||||||
--text-dim: #6b7491;
|
--text-dim: #9ba5c4;
|
||||||
|
|
||||||
--font-display: 'Russo One', sans-serif;
|
--font-display: 'Russo One', sans-serif;
|
||||||
--font-ui: 'Barlow Condensed', sans-serif;
|
--font-ui: 'Barlow Condensed', sans-serif;
|
||||||
@@ -595,16 +595,551 @@ a { color: inherit; text-decoration: none; }
|
|||||||
/* Page wrapper */
|
/* Page wrapper */
|
||||||
.dash-page {
|
.dash-page {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 80px 24px 80px;
|
padding: 88px 24px 80px;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content wrapper */
|
||||||
|
.db-wrap {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 820px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hero */
|
/* Hero */
|
||||||
|
.db-hero {
|
||||||
|
text-align: center;
|
||||||
|
padding: 44px 0 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-hero__name {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(44px, 9vw, 72px);
|
||||||
|
line-height: 1;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text);
|
||||||
|
text-shadow: 0 0 60px rgba(245,197,24,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CTA buttons ── */
|
||||||
|
.db-cta {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 17px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-btn--gold {
|
||||||
|
background: var(--gold);
|
||||||
|
color: #07080f;
|
||||||
|
box-shadow: 0 0 28px rgba(245,197,24,0.3), 0 4px 14px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-btn--gold:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 0 44px rgba(245,197,24,0.45), 0 8px 20px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-btn--blue {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--blue-bright);
|
||||||
|
border: 1.5px solid var(--blue);
|
||||||
|
box-shadow: 0 0 20px rgba(59,130,246,0.15), 0 4px 14px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-btn--blue:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 0 36px rgba(59,130,246,0.28), 0 8px 20px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-btn:active { transform: translateY(0); }
|
||||||
|
|
||||||
|
/* ── Stats strip ── */
|
||||||
|
.db-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 20px 12px 18px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-stat::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(to right, transparent, var(--border-glow), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-stat__val {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(26px, 4vw, 34px);
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-stat__label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sections ── */
|
||||||
|
.db-section {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-section__hd {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-section__title {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-tab {
|
||||||
|
padding: 4px 14px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-tab:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.lb-tab--active {
|
||||||
|
background: var(--gold-dim);
|
||||||
|
color: var(--gold);
|
||||||
|
border: 1px solid var(--gold-mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-section__badge {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--gold);
|
||||||
|
background: var(--gold-dim);
|
||||||
|
border: 1px solid var(--gold-mid);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tier Badge ─────────────────────────────────────────────── */
|
||||||
|
.tier-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--tier-color);
|
||||||
|
background: color-mix(in srgb, var(--tier-color) 10%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--tier-color) 35%, transparent);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 0 10px var(--tier-glow);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-badge--sm {
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tier Progress Bar ─────────────────────────────────────── */
|
||||||
|
.tier-progress {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-progress__labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-progress__pct {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-progress__bar {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-progress__fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 1s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Greeting + Hero ─────────────────────────────────────────── */
|
||||||
|
.db-greeting {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-hero__namerow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-top-pct {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-top-pct strong {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Win Streak ──────────────────────────────────────────────── */
|
||||||
|
.win-streak {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 6px auto 0;
|
||||||
|
padding: 4px 14px;
|
||||||
|
background: rgba(245, 197, 24, 0.06);
|
||||||
|
border: 1px solid rgba(245, 197, 24, 0.2);
|
||||||
|
border-radius: 99px;
|
||||||
|
animation: streakPulse 2.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.win-streak__fire { font-size: 14px; }
|
||||||
|
|
||||||
|
.win-streak__label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes streakPulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(245,197,24,0); }
|
||||||
|
50% { box-shadow: 0 0 14px 2px rgba(245,197,24,0.12); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ELO Hero Block ─────────────────────────────────────────── */
|
||||||
|
.db-elo-block {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 20px 24px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-elo-block::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(to right, transparent, var(--border-glow), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-elo-block__inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-elo-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-elo-val {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(40px, 7vw, 58px);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-elo-label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-elo-rank {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-elo-rank__total {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-elo-block__right {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Two-column layout ─────────────────────────────────────── */
|
||||||
|
.db-two-col {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-section--half {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Match History ──────────────────────────────────────────── */
|
||||||
|
.match-history {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-row:last-child { border-bottom: none; }
|
||||||
|
.match-row:hover { background: rgba(255,255,255,0.02); }
|
||||||
|
|
||||||
|
.match-row--win { border-left: 2px solid #22c55e; }
|
||||||
|
.match-row--loss { border-left: 2px solid #ef4444; }
|
||||||
|
|
||||||
|
.match-chip {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-chip--win { background: rgba(34,197,94,0.12); color: #22c55e; border: 1px solid rgba(34,197,94,0.3); }
|
||||||
|
.match-chip--loss { background: rgba(239,68,68,0.10); color: #ef4444; border: 1px solid rgba(239,68,68,0.3); }
|
||||||
|
|
||||||
|
.match-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-elo-after {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-time {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-elo-delta {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-elo-delta--pos { color: #22c55e; }
|
||||||
|
.match-elo-delta--neg { color: #ef4444; }
|
||||||
|
|
||||||
|
.match-ago {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── All-grades overview ── */
|
||||||
|
.db-grades {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-grade-cell {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 18px 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-grade-cell:hover { border-color: var(--text-muted); transform: translateY(-1px); }
|
||||||
|
|
||||||
|
.db-grade-cell--active {
|
||||||
|
border-color: var(--gold-mid);
|
||||||
|
background: var(--gold-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-grade-cell__top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-grade-cell__name {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-grade-cell__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-grade-cell__val {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-grade-cell__val--elo {
|
||||||
|
color: var(--gold);
|
||||||
|
text-shadow: 0 0 10px rgba(245,197,24,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-grade-cell__unit {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Legacy hero — kept for non-dashboard pages if any */
|
||||||
.dash-hero {
|
.dash-hero {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 48px 0 36px;
|
padding: 48px 0 36px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 740px;
|
max-width: 820px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-hero__eyebrow {
|
.dash-hero__eyebrow {
|
||||||
@@ -670,6 +1205,8 @@ a { color: inherit; text-decoration: none; }
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.dash-cta__btn {
|
.dash-cta__btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -727,6 +1264,7 @@ a { color: inherit; text-decoration: none; }
|
|||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -776,6 +1314,7 @@ a { color: inherit; text-decoration: none; }
|
|||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.dash-section__hd {
|
.dash-section__hd {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -984,6 +1523,38 @@ a { color: inherit; text-decoration: none; }
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ghost (empty slot) rows */
|
||||||
|
.lb-row--ghost {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-row--ghost:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-avatar--ghost {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-ghost-bar {
|
||||||
|
display: block;
|
||||||
|
height: 9px;
|
||||||
|
width: 72px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-ghost-time {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.lb-skeleton {
|
.lb-skeleton {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1027,6 +1598,7 @@ a { color: inherit; text-decoration: none; }
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Error banner */
|
/* Error banner */
|
||||||
.dash-error-banner {
|
.dash-error-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1061,6 +1633,8 @@ a { color: inherit; text-decoration: none; }
|
|||||||
|
|
||||||
.dash-error-banner__dismiss:hover { opacity: 1; }
|
.dash-error-banner__dismiss:hover { opacity: 1; }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* Loading skeleton */
|
/* Loading skeleton */
|
||||||
.skel-pill {
|
.skel-pill {
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
@@ -1290,6 +1864,86 @@ a { color: inherit; text-decoration: none; }
|
|||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-row__action {
|
||||||
|
padding: 6px 16px;
|
||||||
|
background: var(--gold-dim);
|
||||||
|
border: 1px solid var(--gold-mid);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--gold);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s, transform 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row__action:hover {
|
||||||
|
background: var(--gold-mid);
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row__action:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-avatar {
|
||||||
|
position: relative;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
transition: border-color 0.2s, opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-avatar:hover {
|
||||||
|
border-color: var(--gold-mid);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-avatar__img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-avatar__placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-avatar__spinner {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-avatar__spinner::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 12px;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: settings-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes settings-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
.settings-toggle {
|
.settings-toggle {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 48px;
|
width: 48px;
|
||||||
@@ -1360,40 +2014,6 @@ a { color: inherit; text-decoration: none; }
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Username nav elements ────────────────────────────────── */
|
|
||||||
.dash-nav__username {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--text-dim);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
transition: color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-nav__username:hover { color: var(--text); }
|
|
||||||
|
|
||||||
.dash-nav__set-username {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: var(--gold);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
opacity: 0.75;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-nav__set-username:hover { opacity: 1; }
|
|
||||||
|
|
||||||
/* ── Username modal ───────────────────────────────────────── */
|
/* ── Username modal ───────────────────────────────────────── */
|
||||||
.uname-overlay {
|
.uname-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -1514,21 +2134,47 @@ a { color: inherit; text-decoration: none; }
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Forfeit / Quit Button ────────────────────────────────── */
|
||||||
|
.btn-forfeit {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1.5px solid #e57373;
|
||||||
|
background: #fff;
|
||||||
|
color: #c62828;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.btn-forfeit:hover {
|
||||||
|
background: #c62828;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Responsive ───────────────────────────────────────────── */
|
/* ── Responsive ───────────────────────────────────────────── */
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.db-two-col { grid-template-columns: 1fr; }
|
||||||
|
.db-elo-block__inner { flex-direction: column; align-items: flex-start; gap: 16px; }
|
||||||
|
.db-elo-block__right { width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.dash-nav { padding: 0 16px; }
|
.dash-nav { padding: 0 16px; }
|
||||||
|
.dash-page { padding-left: 16px; padding-right: 16px; }
|
||||||
.profile-dropdown__trigger .dash-nav__name { display: none; }
|
.profile-dropdown__trigger .dash-nav__name { display: none; }
|
||||||
.dash-cta__btn { padding: 16px 40px; }
|
.db-stats { grid-template-columns: repeat(2, 1fr); }
|
||||||
.dash-stats { gap: 8px; }
|
.db-stat__val { font-size: clamp(22px, 6vw, 30px); }
|
||||||
.stat-card { padding: 18px 10px; }
|
.db-btn { font-size: 15px; padding: 15px 16px; }
|
||||||
.stat-card__value { font-size: clamp(22px, 6vw, 32px); }
|
|
||||||
.lb-row { padding: 10px 14px; gap: 10px; }
|
.lb-row { padding: 10px 14px; gap: 10px; }
|
||||||
.lb-row--current { padding-left: 12px; }
|
.lb-row--current { padding-left: 12px; }
|
||||||
|
.db-hero__namerow { flex-direction: column; gap: 8px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.grade-badges { gap: 8px; }
|
.grade-badges { gap: 8px; }
|
||||||
.grade-badge { padding: 12px 16px; }
|
.grade-badge { padding: 12px 16px; }
|
||||||
.dash-grade-tab { padding: 8px 14px; font-size: 12px; }
|
.dash-grade-tab { padding: 8px 14px; font-size: 12px; }
|
||||||
.dash-hero__title { font-size: clamp(44px, 14vw, 88px); }
|
.db-hero__name { font-size: clamp(38px, 13vw, 72px); }
|
||||||
|
.db-grades { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
+354
-219
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import ProfileDropdown from '../components/ProfileDropdown';
|
import ProfileDropdown from '../components/ProfileDropdown';
|
||||||
|
|
||||||
@@ -10,12 +10,29 @@ const GRADES = [
|
|||||||
{ id: 'G9', label: 'Grade 9' },
|
{ id: 'G9', label: 'Grade 9' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const LB_SLOTS = 10;
|
||||||
|
|
||||||
|
const TIERS = [
|
||||||
|
{ name: 'Bronze', min: 800, max: 899, color: '#cd7f32', glow: 'rgba(205,127,50,0.45)', next: 900 },
|
||||||
|
{ name: 'Silver', min: 900, max: 1099, color: '#b0bec5', glow: 'rgba(176,190,197,0.45)', next: 1100 },
|
||||||
|
{ name: 'Gold', min: 1100, max: 1299, color: '#f5c518', glow: 'rgba(245,197,24,0.55)', next: 1300 },
|
||||||
|
{ name: 'Platinum', min: 1300, max: 1499, color: '#90caf9', glow: 'rgba(144,202,249,0.5)', next: 1500 },
|
||||||
|
{ name: 'Diamond', min: 1500, max: Infinity, color: '#64ffda', glow: 'rgba(100,255,218,0.55)', next: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getTier(elo) {
|
||||||
|
return TIERS.find(t => elo >= t.min && elo <= t.max) ?? TIERS[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANK_STYLES = [
|
||||||
|
{ color: '#f5c518', textShadow: '0 0 12px rgba(245,197,24,0.6)' },
|
||||||
|
{ color: '#c0ccda', textShadow: '0 0 10px rgba(192,204,218,0.4)' },
|
||||||
|
{ color: '#e0915a', textShadow: '0 0 10px rgba(224,145,90,0.4)' },
|
||||||
|
];
|
||||||
|
|
||||||
function decodeJwt(token) {
|
function decodeJwt(token) {
|
||||||
try {
|
try { return JSON.parse(atob(token.split('.')[1])); }
|
||||||
return JSON.parse(atob(token.split('.')[1]));
|
catch { return null; }
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtTime(ms) {
|
function fmtTime(ms) {
|
||||||
@@ -24,63 +41,134 @@ function fmtTime(ms) {
|
|||||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ label, value, accent = false }) {
|
function fmtRelative(dateStr) {
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime();
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return 'just now';
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
if (hrs < 24) return `${hrs}h ago`;
|
||||||
|
return `${Math.floor(hrs / 24)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGreeting() {
|
||||||
|
const h = new Date().getHours();
|
||||||
|
if (h < 5) return 'Night owl mode';
|
||||||
|
if (h < 12) return 'Good morning';
|
||||||
|
if (h < 17) return 'Good afternoon';
|
||||||
|
return 'Good evening';
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCountUp(target, duration = 900, enabled = true) {
|
||||||
|
const [value, setValue] = useState(0);
|
||||||
|
const rafRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || target == null || target === 0) {
|
||||||
|
setValue(target ?? 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = Date.now();
|
||||||
|
const tick = () => {
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 3);
|
||||||
|
setValue(Math.round(eased * target));
|
||||||
|
if (progress < 1) rafRef.current = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(rafRef.current);
|
||||||
|
}, [target, duration, enabled]);
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TierBadge({ elo, size = 'md' }) {
|
||||||
|
const tier = getTier(elo);
|
||||||
|
const isLg = size === 'lg';
|
||||||
return (
|
return (
|
||||||
<div className="stat-card">
|
<span
|
||||||
<span className="stat-card__label">{label}</span>
|
className="tier-badge"
|
||||||
<span className={`stat-card__value${accent ? ' stat-card__value--accent' : ''}`}>
|
style={{
|
||||||
{value}
|
'--tier-color': tier.color,
|
||||||
|
'--tier-glow': tier.glow,
|
||||||
|
fontSize: isLg ? 12 : 10,
|
||||||
|
padding: isLg ? '4px 12px' : '3px 9px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tier.name}
|
||||||
</span>
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TierProgress({ elo }) {
|
||||||
|
const tier = getTier(elo);
|
||||||
|
if (!tier.next) return null;
|
||||||
|
const pct = Math.round(((elo - tier.min) / (tier.next - tier.min)) * 100);
|
||||||
|
const nextTier = TIERS.find(t => t.min === tier.next);
|
||||||
|
return (
|
||||||
|
<div className="tier-progress">
|
||||||
|
<div className="tier-progress__labels">
|
||||||
|
<span style={{ color: tier.color }}>{tier.name}</span>
|
||||||
|
<span className="tier-progress__pct">{pct}%</span>
|
||||||
|
<span style={{ color: nextTier?.color ?? tier.color }}>{nextTier?.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="tier-progress__bar">
|
||||||
|
<div
|
||||||
|
className="tier-progress__fill"
|
||||||
|
style={{ width: `${pct}%`, background: tier.color, boxShadow: `0 0 8px ${tier.glow}` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const RANK_STYLES = [
|
function LeaderboardTable({ entries, loading, highlightRank, tab }) {
|
||||||
{ color: '#f5c518', textShadow: '0 0 12px rgba(245,197,24,0.6)' },
|
|
||||||
{ color: '#94a3b8', textShadow: '0 0 10px rgba(148,163,184,0.5)' },
|
|
||||||
{ color: '#c9824a', textShadow: '0 0 10px rgba(201,130,74,0.5)' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function LeaderboardTable({ entries, loading, highlightRank, grade }) {
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="lb-skeleton">
|
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
{[...Array(5)].map((_, i) => (
|
{[...Array(LB_SLOTS)].map((_, i) => (
|
||||||
<div key={i} className="lb-skel-row" style={{ animationDelay: `${i * 0.07}s` }} />
|
<div key={i} className="skel-pill" style={{ height: 44, animationDelay: `${i * 0.06}s` }} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!entries.length) {
|
const padded = [
|
||||||
const gradeLabel = GRADES.find(g => g.id === grade)?.label ?? grade;
|
...entries,
|
||||||
return (
|
...[...Array(Math.max(0, LB_SLOTS - entries.length))].map((_, i) => ({
|
||||||
<div className="dash-empty">
|
rank: entries.length + i + 1,
|
||||||
<span className="dash-empty__icon">⏱</span>
|
ghost: true,
|
||||||
<p className="dash-empty__text">No times recorded yet for {gradeLabel}</p>
|
})),
|
||||||
<p className="dash-empty__sub">Complete a quiz to appear on the board</p>
|
];
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="lb-table">
|
<div className="lb-table">
|
||||||
<div className="lb-header">
|
<div className="lb-header">
|
||||||
<span className="lb-header__rank">Rank</span>
|
<span className="lb-header__rank">#</span>
|
||||||
<span className="lb-header__player">Player</span>
|
<span className="lb-header__player">Player</span>
|
||||||
<span className="lb-header__time">Best Time</span>
|
<span className="lb-header__time">{tab === 'elo' ? 'ELO' : 'Best Time'}</span>
|
||||||
</div>
|
</div>
|
||||||
{entries.map(entry => {
|
{padded.map(entry => {
|
||||||
|
if (entry.ghost) {
|
||||||
|
return (
|
||||||
|
<div key={`g${entry.rank}`} className="lb-row lb-row--ghost">
|
||||||
|
<span className="lb-rank lb-rank--num">{entry.rank}</span>
|
||||||
|
<div className="lb-user">
|
||||||
|
<div className="lb-avatar lb-avatar--ghost" />
|
||||||
|
<span className="lb-ghost-bar" />
|
||||||
|
</div>
|
||||||
|
<span className="lb-ghost-time">{tab === 'elo' ? '—' : '—:——'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
const isCurrent = highlightRank !== null && entry.rank === highlightRank;
|
const isCurrent = highlightRank !== null && entry.rank === highlightRank;
|
||||||
const rankStyle = entry.rank <= 3 ? RANK_STYLES[entry.rank - 1] : null;
|
const rankStyle = entry.rank <= 3 ? RANK_STYLES[entry.rank - 1] : null;
|
||||||
const playerLabel = entry.username ? `@${entry.username}` : entry.displayName;
|
const playerLabel = entry.username ? `@${entry.username}` : entry.displayName;
|
||||||
const fallbackLetter = (entry.username ?? entry.displayName)?.[0]?.toUpperCase() ?? '?';
|
const fallbackLetter = (entry.username ?? entry.displayName)?.[0]?.toUpperCase() ?? '?';
|
||||||
return (
|
return (
|
||||||
<div key={entry.rank} className={`lb-row${isCurrent ? ' lb-row--current' : ''}`}>
|
<div key={entry.rank} className={`lb-row${isCurrent ? ' lb-row--current' : ''}`}>
|
||||||
<span
|
<span className={`lb-rank${entry.rank > 3 ? ' lb-rank--num' : ''}`} style={rankStyle ?? undefined}>
|
||||||
className={`lb-rank${entry.rank > 3 ? ' lb-rank--num' : ''}`}
|
|
||||||
style={rankStyle ?? undefined}
|
|
||||||
>
|
|
||||||
{entry.rank}
|
{entry.rank}
|
||||||
</span>
|
</span>
|
||||||
<div className="lb-user">
|
<div className="lb-user">
|
||||||
@@ -91,7 +179,9 @@ function LeaderboardTable({ entries, loading, highlightRank, grade }) {
|
|||||||
<span className="lb-name">{playerLabel}</span>
|
<span className="lb-name">{playerLabel}</span>
|
||||||
{isCurrent && <span className="lb-you">YOU</span>}
|
{isCurrent && <span className="lb-you">YOU</span>}
|
||||||
</div>
|
</div>
|
||||||
<span className="lb-score lb-score--time">{fmtTime(entry.bestTime)}</span>
|
<span className="lb-score lb-score--time">
|
||||||
|
{tab === 'elo' ? (entry.elo?.toLocaleString() ?? '—') : fmtTime(entry.bestTime)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -99,63 +189,47 @@ function LeaderboardTable({ entries, loading, highlightRank, grade }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UsernameModal({ token, current, onClose, onSave }) {
|
function MatchHistory({ matches, loading }) {
|
||||||
const [value, setValue] = useState(current ?? '');
|
if (loading) {
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const inputRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
|
|
||||||
window.addEventListener('keydown', onKey);
|
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) return;
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API}/me/username`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
||||||
body: JSON.stringify({ username: trimmed }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (res.ok) { onSave(data.username); onClose(); }
|
|
||||||
else if (data.error === 'taken') setError('Username already taken — try another.');
|
|
||||||
else setError('3–20 characters, letters, numbers, and underscores only.');
|
|
||||||
} catch {
|
|
||||||
setError('Could not save. Check your connection.');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="uname-overlay" onMouseDown={onClose}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '12px 16px' }}>
|
||||||
<div className="uname-modal" onMouseDown={e => e.stopPropagation()}>
|
{[0,1,2].map(i => <div key={i} className="skel-pill" style={{ height: 52, animationDelay: `${i*0.07}s` }} />)}
|
||||||
<h3 className="uname-modal__title">Set username</h3>
|
</div>
|
||||||
<p className="uname-modal__hint">3–20 chars · letters, numbers, underscores</p>
|
);
|
||||||
<input
|
}
|
||||||
ref={inputRef}
|
if (!matches.length) {
|
||||||
className="uname-input"
|
return (
|
||||||
value={value}
|
<div className="dash-empty">
|
||||||
onChange={e => { setValue(e.target.value); setError(null); }}
|
<div className="dash-empty__icon">⚔️</div>
|
||||||
onKeyDown={e => e.key === 'Enter' && handleSave()}
|
<span className="dash-empty__text">No ranked matches yet</span>
|
||||||
placeholder="e.g. player_one"
|
<span className="dash-empty__sub">Queue up to start climbing</span>
|
||||||
maxLength={20}
|
</div>
|
||||||
/>
|
);
|
||||||
{error && <p className="uname-error">{error}</p>}
|
}
|
||||||
<div className="uname-actions">
|
return (
|
||||||
<button className="uname-btn uname-btn--cancel" onClick={onClose}>Cancel</button>
|
<div className="match-history">
|
||||||
<button className="uname-btn uname-btn--save" onClick={handleSave} disabled={saving}>
|
{matches.map((m, i) => {
|
||||||
{saving ? 'Saving…' : 'Save'}
|
const sign = m.eloChange >= 0 ? '+' : '';
|
||||||
</button>
|
return (
|
||||||
|
<div key={m.id ?? i} className={`match-row${m.won ? ' match-row--win' : ' match-row--loss'}`}>
|
||||||
|
<div className={`match-chip${m.won ? ' match-chip--win' : ' match-chip--loss'}`}>
|
||||||
|
{m.won ? 'W' : 'L'}
|
||||||
|
</div>
|
||||||
|
<div className="match-info">
|
||||||
|
<span className="match-elo-after">{m.eloAfter.toLocaleString()} ELO</span>
|
||||||
|
{m.timeSpentMs != null && (
|
||||||
|
<span className="match-time">{fmtTime(m.timeSpentMs)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="match-right">
|
||||||
|
<span className={`match-elo-delta${m.eloChange >= 0 ? ' match-elo-delta--pos' : ' match-elo-delta--neg'}`}>
|
||||||
|
{sign}{m.eloChange}
|
||||||
|
</span>
|
||||||
|
<span className="match-ago">{fmtRelative(m.playedAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -166,25 +240,17 @@ function LoadingSkeleton() {
|
|||||||
<div className="arena-bg"><div className="arena-grain" /></div>
|
<div className="arena-bg"><div className="arena-grain" /></div>
|
||||||
<nav className="dash-nav">
|
<nav className="dash-nav">
|
||||||
<div className="skel-pill" style={{ width: 140, height: 22 }} />
|
<div className="skel-pill" style={{ width: 140, height: 22 }} />
|
||||||
<div className="skel-pill" style={{ width: 180, height: 36, borderRadius: 999 }} />
|
<div className="skel-pill" style={{ width: 40, height: 40, borderRadius: '50%' }} />
|
||||||
</nav>
|
</nav>
|
||||||
<main className="page dash-page">
|
<main className="page dash-page">
|
||||||
<section className="dash-hero">
|
<div className="db-wrap">
|
||||||
<div className="skel-pill" style={{ width: 96, height: 12, marginBottom: 16 }} />
|
<div className="skel-pill" style={{ width: 280, height: 72, marginBottom: 24, alignSelf: 'center' }} />
|
||||||
<div className="skel-pill" style={{ width: 300, height: 64, marginBottom: 32 }} />
|
<div className="skel-pill" style={{ width: 240, height: 40, borderRadius: 999, marginBottom: 40, alignSelf: 'center' }} />
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div className="skel-pill" style={{ height: 60, marginBottom: 28 }} />
|
||||||
{[0, 1, 2].map(i => (
|
<div className="db-stats">
|
||||||
<div key={i} className="skel-pill" style={{ width: 96, height: 38, animationDelay: `${i * 0.08}s` }} />
|
{[0,1,2,3].map(i => <div key={i} className="skel-pill" style={{ height: 88, animationDelay: `${i*0.07}s` }} />)}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div className="skel-pill" style={{ height: 480 }} />
|
||||||
<div className="dash-cta">
|
|
||||||
<div className="skel-pill" style={{ width: 220, height: 58 }} />
|
|
||||||
</div>
|
|
||||||
<div className="dash-stats">
|
|
||||||
{[0, 1, 2].map(i => (
|
|
||||||
<div key={i} className="skel-pill" style={{ height: 88, animationDelay: `${i * 0.08}s` }} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
@@ -197,16 +263,17 @@ export default function Dashboard() {
|
|||||||
const [jwtPayload, setJwtPayload] = useState(null);
|
const [jwtPayload, setJwtPayload] = useState(null);
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [username, setUsername] = useState(null);
|
const [username, setUsername] = useState(null);
|
||||||
const [usernameModal, setUsernameModal] = useState(false);
|
|
||||||
const [stats, setStats] = useState([]);
|
const [stats, setStats] = useState([]);
|
||||||
const [leaderboard, setLeaderboard] = useState([]);
|
const [leaderboard, setLeaderboard] = useState([]);
|
||||||
|
const [matches, setMatches] = useState([]);
|
||||||
const [selectedGrade, setSelectedGrade] = useState('G3');
|
const [selectedGrade, setSelectedGrade] = useState('G3');
|
||||||
|
const [lbTab, setLbTab] = useState('elo');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [lbLoading, setLbLoading] = useState(false);
|
const [lbLoading, setLbLoading] = useState(false);
|
||||||
|
const [matchLoading, setMatchLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const profileElo = stats.find(e => e.grade === selectedGrade)?.elo ?? null;
|
const [statsReady, setStatsReady] = useState(false);
|
||||||
|
|
||||||
// ── Auth init ──
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const urlToken = params.get('token');
|
const urlToken = params.get('token');
|
||||||
@@ -222,106 +289,115 @@ export default function Dashboard() {
|
|||||||
setJwtPayload(decodeJwt(tok));
|
setJwtPayload(decodeJwt(tok));
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
// ── Fetch user + stats ──
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
const headers = { Authorization: `Bearer ${token}` };
|
const headers = { Authorization: `Bearer ${token}` };
|
||||||
Promise.all([
|
Promise.all([fetch(`${API}/auth/me`, { headers }), fetch(`${API}/me/stats`, { headers })])
|
||||||
fetch(`${API}/auth/me`, { headers }),
|
|
||||||
fetch(`${API}/me/stats`, { headers }),
|
|
||||||
])
|
|
||||||
.then(async ([meRes, statsRes]) => {
|
.then(async ([meRes, statsRes]) => {
|
||||||
if (meRes.status === 401) {
|
if (meRes.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return; }
|
||||||
sessionStorage.removeItem('ranked_token');
|
|
||||||
setLoading(false);
|
|
||||||
navigate('/');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const [meData, statsData] = await Promise.all([meRes.json(), statsRes.json()]);
|
const [meData, statsData] = await Promise.all([meRes.json(), statsRes.json()]);
|
||||||
setUser(meData);
|
setUser(meData);
|
||||||
setUsername(meData.username ?? null);
|
setUsername(meData.username ?? null);
|
||||||
setStats(statsData.entries ?? []);
|
setStats(statsData.entries ?? []);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
requestAnimationFrame(() => setStatsReady(true));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => { setError('Could not reach the server.'); setLoading(false); });
|
||||||
setError('Could not reach the server. Check your connection and refresh.');
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}, [token, navigate]);
|
}, [token, navigate]);
|
||||||
|
|
||||||
// ── Fetch leaderboard ──
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
setLbLoading(true);
|
setLbLoading(true);
|
||||||
fetch(`${API}/leaderboard?grade=${selectedGrade}`)
|
fetch(`${API}/leaderboard?grade=${selectedGrade}&type=${lbTab}`)
|
||||||
.then(res => res.json())
|
.then(r => r.json())
|
||||||
.then(data => setLeaderboard(Array.isArray(data) ? data : []))
|
.then(d => setLeaderboard(Array.isArray(d) ? d : []))
|
||||||
.catch(() => setLeaderboard([]))
|
.catch(() => setLeaderboard([]))
|
||||||
.finally(() => setLbLoading(false));
|
.finally(() => setLbLoading(false));
|
||||||
|
}, [token, selectedGrade, lbTab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
setMatchLoading(true);
|
||||||
|
fetch(`${API}/me/recent-matches?grade=${selectedGrade}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => setMatches(Array.isArray(d) ? d : []))
|
||||||
|
.catch(() => setMatches([]))
|
||||||
|
.finally(() => setMatchLoading(false));
|
||||||
}, [token, selectedGrade]);
|
}, [token, selectedGrade]);
|
||||||
|
|
||||||
const signOut = () => {
|
const signOut = () => { sessionStorage.removeItem('ranked_token'); navigate('/'); };
|
||||||
sessionStorage.removeItem('ranked_token');
|
|
||||||
navigate('/');
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentEntry = stats.find(e => e.grade === selectedGrade);
|
const currentEntry = stats.find(e => e.grade === selectedGrade);
|
||||||
const highlightRank = currentEntry?.timeRank ?? null;
|
const highlightRank = lbTab === 'elo' ? (currentEntry?.eloRank ?? null) : (currentEntry?.timeRank ?? null);
|
||||||
|
|
||||||
const displayName = user?.displayName ?? jwtPayload?.name ?? 'Player';
|
const displayName = user?.displayName ?? jwtPayload?.name ?? 'Player';
|
||||||
const firstName = displayName.split(' ')[0];
|
const firstName = displayName.split(' ')[0];
|
||||||
const avatarUrl = user?.avatarUrl ?? jwtPayload?.picture ?? null;
|
const avatarUrl = user?.avatarUrl ?? jwtPayload?.picture ?? null;
|
||||||
const initial = displayName[0]?.toUpperCase() ?? '?';
|
const initial = displayName[0]?.toUpperCase() ?? '?';
|
||||||
|
const elo = currentEntry?.elo ?? 800;
|
||||||
|
const tier = getTier(elo);
|
||||||
|
const winStreak = currentEntry?.winStreak ?? 0;
|
||||||
|
const eloRank = currentEntry?.eloRank ?? null;
|
||||||
|
const totalPlayers = currentEntry?.totalPlayers ?? 0;
|
||||||
|
const gamesPlayed = currentEntry?.gamesPlayed ?? 0;
|
||||||
|
|
||||||
|
const topPct = eloRank && totalPlayers > 0
|
||||||
|
? Math.max(1, Math.round((eloRank / totalPlayers) * 100))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const animatedElo = useCountUp(elo, 1000, statsReady);
|
||||||
|
const animatedGames = useCountUp(gamesPlayed, 800, statsReady);
|
||||||
|
|
||||||
if (loading) return <LoadingSkeleton />;
|
if (loading) return <LoadingSkeleton />;
|
||||||
|
|
||||||
|
const heroLabel = username ?? firstName;
|
||||||
|
|
||||||
|
const casualLabel = gamesPlayed === 0 ? 'Play First Game' : 'Casual';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="arena-bg"><div className="arena-grain" /></div>
|
<div className="arena-bg"><div className="arena-grain" /></div>
|
||||||
|
|
||||||
{usernameModal && (
|
|
||||||
<UsernameModal
|
|
||||||
token={token}
|
|
||||||
current={username}
|
|
||||||
onClose={() => setUsernameModal(false)}
|
|
||||||
onSave={(u) => setUsername(u)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Nav ── */}
|
|
||||||
<nav className="dash-nav">
|
<nav className="dash-nav">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
|
||||||
<span className="dash-nav__logo">RANKED<span>EQAO</span></span>
|
<span className="dash-nav__logo">RANKED<span>EQAO</span></span>
|
||||||
{profileElo !== null && <span style={{
|
|
||||||
fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600,
|
|
||||||
color: 'var(--gold)', letterSpacing: '0.04em',
|
|
||||||
padding: '4px 12px', borderRadius: 999,
|
|
||||||
background: 'var(--gold-dim)',
|
|
||||||
border: '1px solid var(--gold-mid)',
|
|
||||||
}}>
|
|
||||||
⚡ ELO: {profileElo.toLocaleString()}
|
|
||||||
</span>}
|
|
||||||
</div>
|
|
||||||
<div className="dash-nav__right">
|
<div className="dash-nav__right">
|
||||||
{username
|
<ProfileDropdown displayName={displayName} avatarUrl={avatarUrl} initial={initial} onSignOut={signOut} />
|
||||||
? <button className="dash-nav__username" onClick={() => setUsernameModal(true)}>@{username}</button>
|
|
||||||
: <button className="dash-nav__set-username" onClick={() => setUsernameModal(true)}>Set username</button>
|
|
||||||
}
|
|
||||||
<ProfileDropdown
|
|
||||||
displayName={displayName}
|
|
||||||
avatarUrl={avatarUrl}
|
|
||||||
initial={initial}
|
|
||||||
onSignOut={signOut}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main className="page dash-page">
|
<main className="page dash-page">
|
||||||
|
<div className="db-wrap">
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="dash-error-banner" style={{ maxWidth: '100%', marginBottom: 24 }}>
|
||||||
|
<span>{error}</span>
|
||||||
|
<button className="dash-error-banner__dismiss" onClick={() => setError(null)}>✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Hero ── */}
|
{/* ── Hero ── */}
|
||||||
<section className="dash-hero">
|
<section className="db-hero">
|
||||||
<h1 className="dash-hero__title">{username ?? firstName}</h1>
|
<p className="db-greeting">{getGreeting()},</p>
|
||||||
<div className="dash-grade-tabs">
|
<div className="db-hero__namerow">
|
||||||
|
<h1 className="db-hero__name">{heroLabel}</h1>
|
||||||
|
<TierBadge elo={elo} size="lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{winStreak >= 2 && (
|
||||||
|
<div className="win-streak">
|
||||||
|
<span className="win-streak__fire">🔥</span>
|
||||||
|
<span className="win-streak__label">{winStreak} win streak</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{topPct && (
|
||||||
|
<p className="db-top-pct">
|
||||||
|
Top <strong>{topPct}%</strong> in Grade {selectedGrade.replace('G', '')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="dash-grade-tabs" style={{ marginTop: 20 }}>
|
||||||
{GRADES.map(g => (
|
{GRADES.map(g => (
|
||||||
<button
|
<button
|
||||||
key={g.id}
|
key={g.id}
|
||||||
@@ -334,71 +410,130 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── CTA Buttons ── */}
|
{/* ── CTA buttons ── */}
|
||||||
<div className="dash-cta">
|
<div className="db-cta">
|
||||||
<button className="dash-cta__btn" onClick={() => navigate('/game', { state: { grade: selectedGrade } })}>
|
<button className="db-btn db-btn--gold" onClick={() => navigate('/game', { state: { grade: selectedGrade } })}>
|
||||||
<span className="dash-cta__label">Start Game</span>
|
{casualLabel}
|
||||||
</button>
|
</button>
|
||||||
<button className="dash-cta__btn dash-cta__btn--ranked" onClick={() => navigate('/queue', { state: { grade: selectedGrade } })}>
|
<button className="db-btn db-btn--blue" onClick={() => navigate('/queue', { state: { grade: selectedGrade } })}>
|
||||||
<span className="dash-cta__label">Queue Ranked</span>
|
Queue Ranked
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Error banner ── */}
|
{/* ── ELO + Tier progress ── */}
|
||||||
{error && (
|
<div className="db-elo-block">
|
||||||
<div className="dash-error-banner">
|
<div className="db-elo-block__inner">
|
||||||
<span>{error}</span>
|
<div className="db-elo-main">
|
||||||
<button className="dash-error-banner__dismiss" onClick={() => setError(null)}>✕</button>
|
<span className="db-elo-val" style={{ color: tier.color, textShadow: `0 0 24px ${tier.glow}` }}>
|
||||||
</div>
|
{animatedElo.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className="db-elo-label">ELO</span>
|
||||||
|
{eloRank && (
|
||||||
|
<span className="db-elo-rank">
|
||||||
|
#{eloRank}
|
||||||
|
{totalPlayers > 0 && <span className="db-elo-rank__total"> / {totalPlayers}</span>}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{/* ── Stats row ── */}
|
<div className="db-elo-block__right">
|
||||||
<div className="dash-stats">
|
<TierProgress elo={elo} />
|
||||||
<StatCard
|
</div>
|
||||||
label="Best Time"
|
</div>
|
||||||
value={fmtTime(currentEntry?.bestTime)}
|
|
||||||
accent
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Games Played"
|
|
||||||
value={currentEntry?.gamesPlayed ?? '—'}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="ELO"
|
|
||||||
value={currentEntry?.elo?.toLocaleString() ?? '—'}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Leaderboard ── */}
|
{/* ── Stats strip ── */}
|
||||||
<section className="dash-section">
|
<div className="db-stats">
|
||||||
<div className="dash-section__hd">
|
{[
|
||||||
<h2 className="dash-section__title">Leaderboard</h2>
|
{ label: 'Best Time', value: fmtTime(currentEntry?.bestTime), accent: true },
|
||||||
<span className="dash-section__badge">Best Time</span>
|
{ label: 'Games', value: animatedGames },
|
||||||
|
{ label: 'Correct', value: currentEntry?.totalCorrect ?? 0 },
|
||||||
|
{ label: 'Time Rank', value: currentEntry?.timeRank != null ? `#${currentEntry.timeRank}` : '—' },
|
||||||
|
].map(s => (
|
||||||
|
<div key={s.label} className="db-stat">
|
||||||
|
<span className="db-stat__val" style={s.accent ? { color: 'var(--gold)', textShadow: '0 0 18px rgba(245,197,24,0.35)' } : {}}>
|
||||||
|
{s.value}
|
||||||
|
</span>
|
||||||
|
<span className="db-stat__label">{s.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Recent Matches + Leaderboard side by side ── */}
|
||||||
|
<div className="db-two-col">
|
||||||
|
|
||||||
|
{/* Recent Matches */}
|
||||||
|
<section className="db-section db-section--half">
|
||||||
|
<div className="db-section__hd">
|
||||||
|
<span className="db-section__title">Recent Matches</span>
|
||||||
|
{winStreak >= 2 && (
|
||||||
|
<span className="db-section__badge" style={{ background: 'rgba(245,197,24,0.08)', borderColor: 'rgba(245,197,24,0.3)', color: 'var(--gold)' }}>
|
||||||
|
🔥 {winStreak} streak
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="dash-card">
|
<div className="dash-card">
|
||||||
<LeaderboardTable
|
<MatchHistory matches={matches} loading={matchLoading} />
|
||||||
entries={leaderboard}
|
|
||||||
loading={lbLoading}
|
|
||||||
highlightRank={highlightRank}
|
|
||||||
grade={selectedGrade}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Recent Activity ── */}
|
{/* Leaderboard */}
|
||||||
<section className="dash-section">
|
<section className="db-section db-section--half">
|
||||||
<div className="dash-section__hd">
|
<div className="db-section__hd">
|
||||||
<h2 className="dash-section__title">Recent Activity</h2>
|
<span className="db-section__title">Leaderboard</span>
|
||||||
|
<div className="lb-tabs">
|
||||||
|
<button className={`lb-tab${lbTab === 'elo' ? ' lb-tab--active' : ''}`} onClick={() => setLbTab('elo')}>ELO</button>
|
||||||
|
<button className={`lb-tab${lbTab === 'time' ? ' lb-tab--active' : ''}`} onClick={() => setLbTab('time')}>Time</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="dash-card">
|
<div className="dash-card">
|
||||||
<div className="dash-empty">
|
<LeaderboardTable entries={leaderboard} loading={lbLoading} highlightRank={highlightRank} tab={lbTab} />
|
||||||
<span className="dash-empty__icon">🎮</span>
|
|
||||||
<p className="dash-empty__text">No games played yet</p>
|
|
||||||
<p className="dash-empty__sub">Complete a game to see your activity here</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── All-grades overview ── */}
|
||||||
|
<section className="db-section">
|
||||||
|
<div className="db-section__hd">
|
||||||
|
<span className="db-section__title">All Grades</span>
|
||||||
|
</div>
|
||||||
|
<div className="db-grades">
|
||||||
|
{GRADES.map(g => {
|
||||||
|
const e = stats.find(s => s.grade === g.id);
|
||||||
|
const t = getTier(e?.elo ?? 800);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={g.id}
|
||||||
|
className={`db-grade-cell${selectedGrade === g.id ? ' db-grade-cell--active' : ''}`}
|
||||||
|
onClick={() => setSelectedGrade(g.id)}
|
||||||
|
>
|
||||||
|
<div className="db-grade-cell__top">
|
||||||
|
<span className="db-grade-cell__name">{g.label}</span>
|
||||||
|
<span className="tier-badge tier-badge--sm" style={{ '--tier-color': t.color, '--tier-glow': t.glow }}>
|
||||||
|
{t.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="db-grade-cell__row">
|
||||||
|
<span className="db-grade-cell__val db-grade-cell__val--elo" style={{ color: t.color, textShadow: `0 0 10px ${t.glow}` }}>
|
||||||
|
{e?.elo?.toLocaleString() ?? '800'}
|
||||||
|
</span>
|
||||||
|
<span className="db-grade-cell__unit">ELO</span>
|
||||||
|
</div>
|
||||||
|
<div className="db-grade-cell__row">
|
||||||
|
<span className="db-grade-cell__val">{fmtTime(e?.bestTime)}</span>
|
||||||
|
<span className="db-grade-cell__unit">best</span>
|
||||||
|
</div>
|
||||||
|
<div className="db-grade-cell__row">
|
||||||
|
<span className="db-grade-cell__val">{e?.gamesPlayed ?? 0}</span>
|
||||||
|
<span className="db-grade-cell__unit">games</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+42
-25
@@ -63,6 +63,12 @@ const NAV_BTN = {
|
|||||||
border: '1.5px solid #b6d8f5', background: '#e8f4fd', color: '#1a73e8',
|
border: '1.5px solid #b6d8f5', background: '#e8f4fd', color: '#1a73e8',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const QUIT_BTN = {
|
||||||
|
padding: '14px 28px', borderRadius: 999, fontFamily: 'system-ui,sans-serif',
|
||||||
|
fontSize: 14, fontWeight: 600, cursor: 'pointer',
|
||||||
|
border: '1.5px solid #e57373', background: '#fff', color: '#c62828',
|
||||||
|
};
|
||||||
|
|
||||||
const WRAP = { display: 'flex', flexDirection: 'column', minHeight: '100vh', background: '#fff', fontFamily: 'system-ui,sans-serif' };
|
const WRAP = { display: 'flex', flexDirection: 'column', minHeight: '100vh', background: '#fff', fontFamily: 'system-ui,sans-serif' };
|
||||||
|
|
||||||
// ── Screens ────────────────────────────────────────────────────
|
// ── Screens ────────────────────────────────────────────────────
|
||||||
@@ -89,7 +95,7 @@ function StageIntro({ stage, onNext }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext, onBack, onJump }) {
|
function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext, onBack, onJump, onQuit }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e) => {
|
const handler = (e) => {
|
||||||
const i = +e.key - 1;
|
const i = +e.key - 1;
|
||||||
@@ -147,7 +153,9 @@ function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ borderTop: '1px solid #e0e0e0', boxShadow: '0 -2px 8px rgba(0,0,0,0.06)', padding: '16px 32px', display: 'flex', justifyContent: 'flex-end', gap: 14 }}>
|
<div style={{ borderTop: '1px solid #e0e0e0', boxShadow: '0 -2px 8px rgba(0,0,0,0.06)', padding: '16px 32px', display: 'flex', justifyContent: 'space-between', gap: 14 }}>
|
||||||
|
<button style={QUIT_BTN} onClick={onQuit}>Quit</button>
|
||||||
|
<div style={{ display: 'flex', gap: 14 }}>
|
||||||
{qIdx > 0 && <button style={NAV_BTN} onClick={onBack}>‹ Back</button>}
|
{qIdx > 0 && <button style={NAV_BTN} onClick={onBack}>‹ Back</button>}
|
||||||
{qIdx === STAGE_SIZE - 1 ? (
|
{qIdx === STAGE_SIZE - 1 ? (
|
||||||
<button style={{ padding: '14px 40px', borderRadius: 999, fontSize: 17, fontWeight: 700, cursor: 'pointer', border: 'none', fontFamily: 'system-ui,sans-serif', background: '#ff69b4', color: '#000', boxShadow: '0 0 6px #ff69b4aa' }} onClick={onNext}>
|
<button style={{ padding: '14px 40px', borderRadius: 999, fontSize: 17, fontWeight: 700, cursor: 'pointer', border: 'none', fontFamily: 'system-ui,sans-serif', background: '#ff69b4', color: '#000', boxShadow: '0 0 6px #ff69b4aa' }} onClick={onNext}>
|
||||||
@@ -159,6 +167,7 @@ function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,22 +265,7 @@ function StageResult({ stage, wrongCount, questions, answers: initialAnswers, on
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompleteScreen({ duration, grade, onDashboard }) {
|
function CompleteScreen({ duration, isNewBest, onDashboard }) {
|
||||||
const [pbState, setPbState] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const token = sessionStorage.getItem('ranked_token');
|
|
||||||
if (!token || !grade) { setPbState({ updated: false }); return; }
|
|
||||||
fetch(`${API}/me/best-time`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
||||||
body: JSON.stringify({ grade, timeMs: duration }),
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => setPbState({ updated: data.updated ?? false }))
|
|
||||||
.catch(() => setPbState({ updated: false }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={WRAP}>
|
<div style={WRAP}>
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '40px 32px', textAlign: 'center', gap: 20 }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '40px 32px', textAlign: 'center', gap: 20 }}>
|
||||||
@@ -281,9 +275,9 @@ function CompleteScreen({ duration, grade, onDashboard }) {
|
|||||||
<div style={{ padding: '14px 40px', background: '#f5f7ff', border: '2px solid #c5d3f8', borderRadius: 12, fontSize: 24, fontWeight: 700, color: '#1a73e8' }}>
|
<div style={{ padding: '14px 40px', background: '#f5f7ff', border: '2px solid #c5d3f8', borderRadius: 12, fontSize: 24, fontWeight: 700, color: '#1a73e8' }}>
|
||||||
⏱ {fmtDuration(duration)}
|
⏱ {fmtDuration(duration)}
|
||||||
</div>
|
</div>
|
||||||
{pbState?.updated && (
|
{isNewBest && (
|
||||||
<div style={{ padding: '10px 28px', background: 'rgba(245,197,24,0.12)', border: '1.5px solid rgba(245,197,24,0.5)', borderRadius: 10, fontSize: 15, fontWeight: 700, color: '#b8860b', letterSpacing: '0.04em' }}>
|
<div style={{ padding: '10px 28px', background: 'rgba(245,197,24,0.12)', border: '1.5px solid rgba(245,197,24,0.5)', borderRadius: 10, fontSize: 15, fontWeight: 700, color: '#b8860b', letterSpacing: '0.04em', boxShadow: '0 0 12px rgba(245,197,24,0.45)' }}>
|
||||||
★ New Personal Best!
|
🏆 New Personal Best!
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button onClick={onDashboard} style={{ padding: '13px 40px', background: '#1a73e8', color: '#fff', border: 'none', borderRadius: 999, fontSize: 16, fontWeight: 600, cursor: 'pointer', fontFamily: 'system-ui,sans-serif' }}>
|
<button onClick={onDashboard} style={{ padding: '13px 40px', background: '#1a73e8', color: '#fff', border: 'none', borderRadius: 999, fontSize: 16, fontWeight: 600, cursor: 'pointer', fontFamily: 'system-ui,sans-serif' }}>
|
||||||
@@ -311,6 +305,7 @@ export default function Game() {
|
|||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [elapsed, setElapsed] = useState(0);
|
const [elapsed, setElapsed] = useState(0);
|
||||||
const [fetchError, setFetchError] = useState(false);
|
const [fetchError, setFetchError] = useState(false);
|
||||||
|
const [isNewBest, setIsNewBest] = useState(false);
|
||||||
|
|
||||||
const timerStartRef = useRef(null);
|
const timerStartRef = useRef(null);
|
||||||
const intervalRef = useRef(null);
|
const intervalRef = useRef(null);
|
||||||
@@ -418,13 +413,35 @@ export default function Game() {
|
|||||||
setPhase('question');
|
setPhase('question');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleResultNext() {
|
function handleQuit() {
|
||||||
|
if (!window.confirm('Are you sure you want to quit? Your progress will be lost.')) return;
|
||||||
|
stopTimer();
|
||||||
|
navigate('/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResultNext() {
|
||||||
if (stage === 1) {
|
if (stage === 1) {
|
||||||
s1CorrectRef.current = STAGE_SIZE - wrongCount;
|
s1CorrectRef.current = STAGE_SIZE - wrongCount;
|
||||||
loadStage(2);
|
loadStage(2);
|
||||||
} else {
|
} else {
|
||||||
endSession(s1CorrectRef.current + (STAGE_SIZE - wrongCount));
|
endSession(s1CorrectRef.current + (STAGE_SIZE - wrongCount));
|
||||||
setDuration(stopTimer());
|
const token = sessionStorage.getItem('ranked_token');
|
||||||
|
const timeMs = timerStartRef.current ? Date.now() - timerStartRef.current : 0;
|
||||||
|
stopTimer();
|
||||||
|
let isNewBest = false;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/me/best-time`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ grade, timeMs }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
isNewBest = data.updated === true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to record best time:', e);
|
||||||
|
}
|
||||||
|
setDuration(timeMs);
|
||||||
|
setIsNewBest(isNewBest);
|
||||||
setPhase('complete');
|
setPhase('complete');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -441,9 +458,9 @@ export default function Game() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
if (phase === 'intro') return <StageIntro stage={stage} onNext={() => { if (stage === 1) { startTimer(); startSession(); } setPhase('question'); }} />;
|
if (phase === 'intro') return <StageIntro stage={stage} onNext={() => { if (stage === 1) { startTimer(); startSession(); } setPhase('question'); }} />;
|
||||||
if (phase === 'question') return <QuestionScreen question={questions[qIdx]} qIdx={qIdx} answers={answers} selected={selected} onSelect={setSelected} onNext={handleNext} onBack={handleBack} onJump={handleJump} />;
|
if (phase === 'question') return <QuestionScreen question={questions[qIdx]} qIdx={qIdx} answers={answers} selected={selected} onSelect={setSelected} onNext={handleNext} onBack={handleBack} onJump={handleJump} onQuit={handleQuit} />;
|
||||||
if (phase === 'result') return <StageResult stage={stage} wrongCount={wrongCount} questions={questions} answers={answers} onRetry={handleRetry} onNext={handleResultNext} />;
|
if (phase === 'result') return <StageResult stage={stage} wrongCount={wrongCount} questions={questions} answers={answers} onRetry={handleRetry} onNext={handleResultNext} />;
|
||||||
if (phase === 'complete') return <CompleteScreen duration={duration} grade={grade} onDashboard={() => navigate('/dashboard')} />;
|
if (phase === 'complete') return <CompleteScreen duration={duration} isNewBest={isNewBest} onDashboard={() => navigate('/dashboard')} />;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+49
-382
@@ -2,31 +2,14 @@ import { useState, useEffect, useRef } from 'react';
|
|||||||
import { useNavigate, useLocation, Navigate } from 'react-router-dom';
|
import { useNavigate, useLocation, Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
const VALID_GRADES = new Set(['G3', 'G6', 'G9']);
|
const VALID_GRADES = new Set(['G3', 'G6', 'G9']);
|
||||||
|
|
||||||
const STAGE_SIZE = 11;
|
|
||||||
|
|
||||||
const API = import.meta.env.VITE_API_URL;
|
const API = import.meta.env.VITE_API_URL;
|
||||||
const NAV_BTN = {
|
|
||||||
padding: '14px 40px', borderRadius: 999,
|
|
||||||
fontFamily: 'system-ui,sans-serif', fontSize: 17, fontWeight: 600,
|
|
||||||
cursor: 'pointer', border: '1.5px solid #b6d8f5',
|
|
||||||
background: '#e8f4fd', color: '#1a73e8',
|
|
||||||
};
|
|
||||||
|
|
||||||
function OpponentBar({ progress, total, name }) {
|
function authHeaders() {
|
||||||
const pct = (progress / total) * 100;
|
const token = sessionStorage.getItem('ranked_token');
|
||||||
return (
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
<div style={{ marginTop: 8, padding: '8px 12px', background: '#fef9f0', borderRadius: 8, border: '1px solid #fce4c8', display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
<span style={{ fontSize: 13, fontWeight: 700, color: '#e67e22', whiteSpace: 'nowrap', fontFamily: 'system-ui,sans-serif', letterSpacing: '0.02em' }}>{name}</span>
|
|
||||||
<div style={{ flex: 1, height: 8, borderRadius: 4, background: '#f5e6d3', overflow: 'hidden' }}>
|
|
||||||
<div style={{ width: `${pct}%`, height: '100%', background: 'linear-gradient(90deg, #f39c12, #e67e22)', borderRadius: 4, transition: 'width 0.6s ease' }} />
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: 13, fontWeight: 700, color: '#e67e22', minWidth: 36, textAlign: 'right', fontFamily: 'system-ui,sans-serif' }}>{Math.min(Math.round(progress), total)}/{total}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function FindingScreen({ elapsed, onBack, onForceMatch, showForceMatch }) {
|
function FindingScreen({ elapsed, onBack }) {
|
||||||
const [dots, setDots] = useState('');
|
const [dots, setDots] = useState('');
|
||||||
useEffect(() => { const t = setInterval(() => setDots(d => d.length >= 3 ? '' : d + '.'), 400); return () => clearInterval(t); }, []);
|
useEffect(() => { const t = setInterval(() => setDots(d => d.length >= 3 ? '' : d + '.'), 400); return () => clearInterval(t); }, []);
|
||||||
const minutes = Math.floor(elapsed / 60);
|
const minutes = Math.floor(elapsed / 60);
|
||||||
@@ -40,165 +23,11 @@ function FindingScreen({ elapsed, onBack, onForceMatch, showForceMatch }) {
|
|||||||
Time elapsed: {minutes}:{String(seconds).padStart(2, '0')}
|
Time elapsed: {minutes}:{String(seconds).padStart(2, '0')}
|
||||||
</div>
|
</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>
|
<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>
|
||||||
{showForceMatch && (
|
|
||||||
<button onClick={onForceMatch} style={{ padding: '14px 36px', fontSize: 16, fontWeight: 700, background: '#ff69b4', color: '#000', border: 'none', borderRadius: 999, cursor: 'pointer', fontFamily: 'system-ui,sans-serif', boxShadow: '0 0 8px #ff69b4aa' }}>★ Force Match</button>
|
|
||||||
)}
|
|
||||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlayerChip({ name, avatar, reverse }) {
|
|
||||||
const pic = avatar
|
|
||||||
? <img src={avatar} alt="" style={{ width: 56, height: 56, borderRadius: '50%', flexShrink: 0 }} referrerPolicy="no-referrer" />
|
|
||||||
: <div style={{ width: 56, height: 56, borderRadius: '50%', background: '#1a73e8', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 22, fontWeight: 700, flexShrink: 0 }}>{name?.[0]?.toUpperCase() ?? '?'}</div>;
|
|
||||||
const label = <span style={{ fontSize: 17, fontWeight: 700, color: '#222', fontFamily: 'system-ui,sans-serif' }}>{name}</span>;
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: reverse ? 'row-reverse' : 'row', alignItems: 'center', gap: 12 }}>
|
|
||||||
{pic}{label}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StageIntro({ grade, myName, myAvatar, opponentName, opponentAvatar, onNext }) {
|
|
||||||
const [count, setCount] = useState(5);
|
|
||||||
const gradeNum = grade?.replace('G', '') ?? '';
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const id = setInterval(() => {
|
|
||||||
setCount(c => {
|
|
||||||
if (c <= 1) { clearInterval(id); onNext(); return 0; }
|
|
||||||
return c - 1;
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, [onNext]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#fff', fontFamily: 'system-ui,sans-serif', gap: 28 }}>
|
|
||||||
<p style={{ fontSize: 11, color: '#999', letterSpacing: 3, textTransform: 'uppercase', margin: 0 }}>Ranked Match</p>
|
|
||||||
<h1 style={{ fontSize: 'clamp(30px,5vw,52px)', fontWeight: 800, color: '#111', margin: 0, textAlign: 'center' }}>EQAO Grade {gradeNum}</h1>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 20, flexWrap: 'wrap', justifyContent: 'center' }}>
|
|
||||||
<PlayerChip name={myName} avatar={myAvatar} reverse={false} />
|
|
||||||
<span style={{ fontSize: 18, fontWeight: 700, color: '#aaa', letterSpacing: 2 }}>vs</span>
|
|
||||||
<PlayerChip name={opponentName} avatar={opponentAvatar} reverse={true} />
|
|
||||||
</div>
|
|
||||||
<p style={{ fontSize: 14, color: '#aaa', margin: 0 }}>First to finish all {STAGE_SIZE} questions wins.</p>
|
|
||||||
<div style={{ fontSize: 'clamp(88px,18vw,144px)', fontWeight: 900, color: '#e53935', lineHeight: 1, fontVariantNumeric: 'tabular-nums', minWidth: 120, textAlign: 'center' }}>{count}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProgressStrip({ total, current, answers, onJump }) {
|
|
||||||
const items = [];
|
|
||||||
for (let qi = 0; qi < total; qi++) {
|
|
||||||
const active = qi === current, answered = answers[qi] != null, filled = answered;
|
|
||||||
items.push(
|
|
||||||
<button key={`b-${qi}`} onClick={() => onJump && onJump(qi)} style={{ width: 36, height: 36, borderRadius: '50%', border: `2px solid ${filled || active ? '#1a73e8' : '#ccc'}`, background: filled ? '#1a73e8' : active ? '#e8f0fe' : '#fff', color: filled ? '#fff' : active ? '#1a73e8' : '#aaa', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, fontWeight: 700, fontFamily: 'system-ui,-apple-system,sans-serif', flexShrink: 0, cursor: 'pointer', padding: 0, boxSizing: 'border-box', transition: 'background 0.15s, border-color 0.15s' }}>{qi + 1}</button>
|
|
||||||
);
|
|
||||||
if (qi < total - 1) items.push(<div key={`l-${qi}`} style={{ flex: 1, height: 3, minWidth: 2, background: answers[qi] != null ? '#1a73e8' : '#ddd' }} />);
|
|
||||||
}
|
|
||||||
return <div style={{ display: 'flex', alignItems: 'center', flexWrap: 'nowrap', width: '100%' }}>{items}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext, onBack, onJump, opponentProgress, opponentName }) {
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e) => {
|
|
||||||
const i = +e.key - 1;
|
|
||||||
if (i >= 0 && i < q.choices.length) { onSelect(i); return; }
|
|
||||||
if ((e.key === 'Enter' || e.key === 'ArrowRight') && qIdx < STAGE_SIZE - 1) onNext();
|
|
||||||
if (e.key === 'ArrowLeft' && qIdx > 0) onBack();
|
|
||||||
};
|
|
||||||
window.addEventListener('keydown', handler);
|
|
||||||
return () => window.removeEventListener('keydown', handler);
|
|
||||||
}, [q.choices.length, qIdx, onSelect, onNext, onBack]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ background: '#cce8f4', minHeight: '100vh', fontFamily: 'system-ui,-apple-system,sans-serif' }}>
|
|
||||||
<div style={{ maxWidth: 760, margin: '0 auto', background: '#fff', minHeight: '100vh', display: 'flex', flexDirection: 'column', boxShadow: '0 0 40px rgba(0,60,120,0.10)' }}>
|
|
||||||
<div style={{ borderBottom: '1px solid #e8e8e8', padding: '20px 32px 16px' }}>
|
|
||||||
<p style={{ fontSize: 36, fontWeight: 700, color: '#333', margin: '0 0 14px 0', lineHeight: 1 }}>Question {qIdx + 1} of {STAGE_SIZE}</p>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#1a73e8', textTransform: 'uppercase', letterSpacing: '0.05em', fontFamily: 'system-ui,sans-serif' }}>You</span>
|
|
||||||
<div style={{ flex: 1 }}><ProgressStrip total={STAGE_SIZE} current={qIdx} answers={answers} onJump={onJump} /></div>
|
|
||||||
</div>
|
|
||||||
<OpponentBar progress={opponentProgress} total={STAGE_SIZE} name={opponentName} />
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, padding: '28px 32px 24px' }}>
|
|
||||||
<p style={{ fontSize: 24, color: '#111', lineHeight: 1.7, whiteSpace: 'pre-line', fontWeight: 500, margin: '0 0 28px 0' }}>{q.question}</p>
|
|
||||||
<div style={q.layout === 'grid' ? { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 } : { display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
||||||
{q.choices.map((choice, i) => {
|
|
||||||
const sel = selected === i;
|
|
||||||
return (
|
|
||||||
<button key={i} onClick={() => onSelect(i)} style={{ minHeight: 64, padding: '18px 24px', background: sel ? '#e8f0fe' : '#f7f8fa', border: `2px solid ${sel ? '#1a73e8' : '#e0e0e0'}`, borderRadius: 12, fontSize: 20, color: sel ? '#1a73e8' : '#222', textAlign: 'left', cursor: 'pointer', fontWeight: sel ? 600 : 400, fontFamily: 'system-ui,-apple-system,sans-serif', lineHeight: 1.45, transition: 'background 0.12s, border-color 0.12s' }}>
|
|
||||||
<span style={{ fontSize: 14, fontWeight: 700, color: sel ? '#1a73e8' : '#aaa', marginRight: 20, opacity: 0.7 }}>{i + 1}</span>{choice}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ borderTop: '1px solid #e0e0e0', boxShadow: '0 -2px 8px rgba(0,0,0,0.06)', padding: '16px 32px', display: 'flex', justifyContent: 'flex-end', gap: 14 }}>
|
|
||||||
{qIdx > 0 && <button style={NAV_BTN} onClick={onBack}>‹ Back</button>}
|
|
||||||
{qIdx === STAGE_SIZE - 1 ? (
|
|
||||||
<button style={{ padding: '14px 40px', borderRadius: 999, fontSize: 17, fontWeight: 700, cursor: 'pointer', border: 'none', fontFamily: 'system-ui,sans-serif', background: '#ff69b4', color: '#000', boxShadow: '0 0 6px #ff69b4aa' }} onClick={onNext}>Submit ★</button>
|
|
||||||
) : (
|
|
||||||
<button style={NAV_BTN} onClick={onNext}>Next ›</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DefeatOverlay({ opponentName, onViewResults }) {
|
|
||||||
return (
|
|
||||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.55)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 2000 }}>
|
|
||||||
<div style={{ background: '#fff', borderRadius: 16, padding: '40px 48px', textAlign: 'center', maxWidth: 380, width: '90%', fontFamily: 'system-ui,sans-serif', boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }}>
|
|
||||||
<div style={{ fontSize: 64, marginBottom: 12 }}>💀</div>
|
|
||||||
<h1 style={{ fontSize: 32, fontWeight: 800, color: '#c62828', margin: '0 0 8px' }}>Defeated!</h1>
|
|
||||||
<p style={{ fontSize: 16, color: '#555', margin: '0 0 24px', lineHeight: 1.5 }}>{opponentName} finished before you.</p>
|
|
||||||
<button onClick={onViewResults} style={{ padding: '12px 36px', fontSize: 16, fontWeight: 600, background: '#1a73e8', color: '#fff', border: 'none', borderRadius: 999, cursor: 'pointer', fontFamily: 'system-ui,sans-serif' }}>See Results</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function WaitingScreen({ opponentName, opponentProgress }) {
|
|
||||||
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: 48, height: 48, borderRadius: '50%', border: '3px solid #e0e0e0', borderTopColor: '#ff69b4', animation: 'spin 0.6s linear infinite' }} />
|
|
||||||
<h2 style={{ fontSize: 22, fontWeight: 600, color: '#333', margin: 0 }}>Waiting for {opponentName}...</h2>
|
|
||||||
<p style={{ fontSize: 15, color: '#888', margin: 0 }}>You finished! Now waiting for your opponent to complete.</p>
|
|
||||||
<div style={{ width: 300, marginTop: 8 }}>
|
|
||||||
<OpponentBar progress={opponentProgress} total={STAGE_SIZE} name={opponentName} />
|
|
||||||
</div>
|
|
||||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CompleteScreen({ won, opponentName, duration, eloChange, onDashboard }) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh', background: '#fff', fontFamily: 'system-ui,sans-serif' }}>
|
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '40px 32px', textAlign: 'center', gap: 20 }}>
|
|
||||||
<div style={{ fontSize: 72 }}>{won ? '🏆' : '💀'}</div>
|
|
||||||
<h1 style={{ fontSize: 36, fontWeight: 800, color: won ? '#1b8a2d' : '#c62828', margin: 0 }}>{won ? 'Victory!' : 'Defeated!'}</h1>
|
|
||||||
<p style={{ fontSize: 17, color: '#555', maxWidth: 400, margin: 0 }}>{won ? `You beat ${opponentName}! Well played.` : `${opponentName} beat you. Better luck next time.`}</p>
|
|
||||||
<div style={{ padding: '14px 40px', background: won ? '#f0faf0' : '#fef0f0', border: `2px solid ${won ? '#a5d6a7' : '#ef9a9a'}`, borderRadius: 12, fontSize: 24, fontWeight: 700, color: won ? '#1b8a2d' : '#c62828' }}>
|
|
||||||
⏱ {Math.floor(duration / 1000 / 60)}:{String(Math.floor(duration / 1000) % 60).padStart(2, '0')}
|
|
||||||
</div>
|
|
||||||
{eloChange !== 0 && <div style={{ padding: '10px 24px', borderRadius: 8, fontSize: 18, fontWeight: 700, fontFamily: 'system-ui,sans-serif', color: eloChange > 0 ? '#1b8a2d' : '#c62828' }}>ELO {eloChange > 0 ? '+' : ''}{eloChange}</div>}
|
|
||||||
<button onClick={onDashboard} style={{ padding: '13px 40px', background: '#1a73e8', color: '#fff', border: 'none', borderRadius: 999, fontSize: 16, fontWeight: 600, cursor: 'pointer', fontFamily: 'system-ui,sans-serif' }}>Back to Dashboard</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function authHeaders() {
|
|
||||||
const token = sessionStorage.getItem('ranked_token');
|
|
||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Queue() {
|
export default function Queue() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { state } = useLocation();
|
const { state } = useLocation();
|
||||||
@@ -206,73 +35,19 @@ export default function Queue() {
|
|||||||
|
|
||||||
const [myName, setMyName] = useState('');
|
const [myName, setMyName] = useState('');
|
||||||
const [myAvatar, setMyAvatar] = useState(null);
|
const [myAvatar, setMyAvatar] = useState(null);
|
||||||
|
|
||||||
const [phase, setPhase] = useState('finding');
|
|
||||||
const [questions, setQuestions] = useState([]);
|
|
||||||
const [qIdx, setQIdx] = useState(0);
|
|
||||||
const [answers, setAnswers] = useState({});
|
|
||||||
const [selected, setSelected] = useState(null);
|
|
||||||
const [opponentProgress, setOpponentProgress] = useState(0);
|
|
||||||
const [opponentName, setOpponentName] = useState('');
|
|
||||||
const [opponentAvatar, setOpponentAvatar] = useState(null);
|
|
||||||
const [playerWon, setPlayerWon] = useState(null);
|
|
||||||
const [showDefeatOverlay, setShowDefeatOverlay] = useState(false);
|
|
||||||
const [startTime] = useState(() => Date.now());
|
|
||||||
const [duration, setDuration] = useState(0);
|
|
||||||
const [eloChange, setEloChange] = useState(0);
|
|
||||||
const [queueElapsed, setQueueElapsed] = useState(0);
|
const [queueElapsed, setQueueElapsed] = useState(0);
|
||||||
const [showForceMatch, setShowForceMatch] = useState(false);
|
|
||||||
|
|
||||||
const gameIdRef = useRef(null);
|
|
||||||
const pollRef = useRef(null);
|
const pollRef = useRef(null);
|
||||||
const completedRef = useRef(false);
|
|
||||||
const submittedRef = useRef(false);
|
|
||||||
const joinedRef = useRef(false);
|
const joinedRef = useRef(false);
|
||||||
|
const completedRef = useRef(false);
|
||||||
const leaveTimerRef = useRef(null);
|
const leaveTimerRef = useRef(null);
|
||||||
|
const leavingRef = useRef(false);
|
||||||
|
|
||||||
// Elapsed timer while finding
|
// Elapsed timer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase !== 'finding') return;
|
|
||||||
const id = setInterval(() => setQueueElapsed(e => e + 1), 1000);
|
const id = setInterval(() => setQueueElapsed(e => e + 1), 1000);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [phase]);
|
}, []);
|
||||||
|
|
||||||
// Show force match after 15s
|
|
||||||
useEffect(() => {
|
|
||||||
if (phase !== 'finding') return;
|
|
||||||
const id = setTimeout(() => setShowForceMatch(true), 15000);
|
|
||||||
return () => clearTimeout(id);
|
|
||||||
}, [phase]);
|
|
||||||
|
|
||||||
async function forceMatch() {
|
|
||||||
clearInterval(pollRef.current);
|
|
||||||
console.log('[queue] force match requested');
|
|
||||||
try {
|
|
||||||
const r = await fetch(`${API}/queue/debug`, { headers: authHeaders() });
|
|
||||||
const debug = await r.json();
|
|
||||||
console.log('[queue] debug state:', JSON.stringify(debug));
|
|
||||||
} catch {}
|
|
||||||
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 => {
|
|
||||||
console.log('[queue] force match result:', JSON.stringify(data));
|
|
||||||
if (!data) return;
|
|
||||||
joinedRef.current = true;
|
|
||||||
if (data.status === 'waiting') {
|
|
||||||
pollQueue();
|
|
||||||
} else if (data.status === 'matched') {
|
|
||||||
fetchMatchData(data.gameId);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch own user info
|
// Fetch own user info
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -288,43 +63,43 @@ export default function Queue() {
|
|||||||
|
|
||||||
// Join queue on mount
|
// Join queue on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Cancel any pending leave from a previous unmount (Strict Mode guard)
|
// Cancel any pending leave from a previous mount (handles StrictMode double-invoke)
|
||||||
if (leaveTimerRef.current) {
|
if (leaveTimerRef.current) {
|
||||||
clearTimeout(leaveTimerRef.current);
|
clearTimeout(leaveTimerRef.current);
|
||||||
leaveTimerRef.current = null;
|
leaveTimerRef.current = null;
|
||||||
}
|
}
|
||||||
|
// Reset leaving flag so callbacks work on this mount
|
||||||
|
leavingRef.current = false;
|
||||||
|
joinedRef.current = false;
|
||||||
|
completedRef.current = false;
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
console.log('[queue] joining with grade', grade);
|
|
||||||
fetch(`${API}/queue/join`, {
|
fetch(`${API}/queue/join`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
body: JSON.stringify({ grade }),
|
body: JSON.stringify({ grade }),
|
||||||
})
|
})
|
||||||
.then(r => {
|
.then(r => {
|
||||||
if (r.status === 401) { console.log('[queue] 401 on join'); sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
|
if (r.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
|
||||||
return r.json();
|
return r.json();
|
||||||
})
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
console.log('[queue] join response:', JSON.stringify(data));
|
if (!data || leavingRef.current) return;
|
||||||
if (!data || cancelled) return;
|
|
||||||
joinedRef.current = true;
|
joinedRef.current = true;
|
||||||
if (data.status === 'waiting') {
|
if (data.status === 'waiting') {
|
||||||
pollQueue();
|
pollQueue();
|
||||||
} else if (data.status === 'matched') {
|
} else if (data.status === 'matched') {
|
||||||
fetchMatchData(data.gameId);
|
fetchMatchData();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(e => console.log('[queue] join error:', e));
|
.catch(() => {});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
leavingRef.current = true;
|
||||||
clearInterval(pollRef.current);
|
clearInterval(pollRef.current);
|
||||||
if (joinedRef.current && !gameIdRef.current && !completedRef.current) {
|
if (joinedRef.current && !completedRef.current) {
|
||||||
leaveTimerRef.current = setTimeout(() => {
|
leaveTimerRef.current = setTimeout(() => {
|
||||||
fetch(`${API}/queue/leave`, { method: 'DELETE', headers: authHeaders() }).catch(() => {});
|
fetch(`${API}/queue/leave`, { method: 'DELETE', headers: authHeaders() }).catch(() => {});
|
||||||
}, 300);
|
}, 0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [grade, navigate]);
|
}, [grade, navigate]);
|
||||||
@@ -333,21 +108,15 @@ export default function Queue() {
|
|||||||
pollRef.current = setInterval(() => {
|
pollRef.current = setInterval(() => {
|
||||||
fetch(`${API}/queue/status`, { headers: authHeaders() })
|
fetch(`${API}/queue/status`, { headers: authHeaders() })
|
||||||
.then(r => {
|
.then(r => {
|
||||||
if (r.status === 401) { console.log('[queue] 401 on poll'); sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
|
if (r.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
|
||||||
return r.json();
|
return r.json();
|
||||||
})
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (!data) return;
|
if (!data || leavingRef.current) return;
|
||||||
if (data.status === 'matched') {
|
if (data.status === 'matched') {
|
||||||
console.log('[queue] poll found match!');
|
|
||||||
clearInterval(pollRef.current);
|
clearInterval(pollRef.current);
|
||||||
gameIdRef.current = data.gameId;
|
navigateToGame(data);
|
||||||
setQuestions(data.questions);
|
|
||||||
setOpponentName(data.opponent.username ?? data.opponent.displayName);
|
|
||||||
setOpponentAvatar(data.opponent.avatarUrl);
|
|
||||||
setPhase('intro');
|
|
||||||
} else if (data.status === 'not_in_queue') {
|
} else if (data.status === 'not_in_queue') {
|
||||||
console.log('[queue] poll got not_in_queue, rejoining');
|
|
||||||
clearInterval(pollRef.current);
|
clearInterval(pollRef.current);
|
||||||
fetch(`${API}/queue/join`, {
|
fetch(`${API}/queue/join`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -355,151 +124,49 @@ export default function Queue() {
|
|||||||
body: JSON.stringify({ grade }),
|
body: JSON.stringify({ grade }),
|
||||||
})
|
})
|
||||||
.then(r => {
|
.then(r => {
|
||||||
if (r.status === 401) { console.log('[queue] 401 on rejoin'); sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
|
if (r.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return null; }
|
||||||
return r.json();
|
return r.json();
|
||||||
})
|
})
|
||||||
.then(d => {
|
.then(d => {
|
||||||
console.log('[queue] rejoin response:', JSON.stringify(d));
|
if (!d || leavingRef.current) return;
|
||||||
if (!d) return;
|
|
||||||
if (d.status === 'waiting') pollQueue();
|
if (d.status === 'waiting') pollQueue();
|
||||||
else if (d.status === 'matched') fetchMatchData(d.gameId);
|
else if (d.status === 'matched') navigateToGame(d);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchMatchData(gameId) {
|
|
||||||
gameIdRef.current = gameId;
|
|
||||||
fetch(`${API}/queue/status`, { headers: authHeaders() })
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.status === 'matched') {
|
|
||||||
setQuestions(data.questions);
|
|
||||||
setOpponentName(data.opponent.username ?? data.opponent.displayName);
|
|
||||||
setOpponentAvatar(data.opponent.avatarUrl);
|
|
||||||
setPhase('intro');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitProgress(currentQuestion, correctAnswers, finished, timeSpentMs) {
|
|
||||||
fetch(`${API}/game/progress`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
|
||||||
body: JSON.stringify({ gameId: gameIdRef.current, currentQuestion, correctAnswers, finished, timeSpentMs }),
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
function pollGameStatus() {
|
|
||||||
const id = gameIdRef.current;
|
|
||||||
if (!id) return;
|
|
||||||
|
|
||||||
pollRef.current = setInterval(() => {
|
|
||||||
if (completedRef.current) { clearInterval(pollRef.current); return; }
|
|
||||||
|
|
||||||
fetch(`${API}/game/status/${id}`, { headers: authHeaders() })
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.status === 'completed') {
|
|
||||||
clearInterval(pollRef.current);
|
|
||||||
const you = data.players.you;
|
|
||||||
const won = data.youWon === true;
|
|
||||||
completedRef.current = true;
|
|
||||||
setDuration(you.timeSpentMs || (Date.now() - startTime));
|
|
||||||
setEloChange(you.eloChange || 0);
|
|
||||||
setPlayerWon(won);
|
|
||||||
if (won) {
|
|
||||||
setPhase('complete');
|
|
||||||
} else {
|
|
||||||
setShowDefeatOverlay(true);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const opp = data.players.opponent;
|
|
||||||
if (opp.username) setOpponentName(opp.username);
|
|
||||||
setOpponentProgress(opp.currentQuestion + 1);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNext() {
|
function fetchMatchData() {
|
||||||
const newAnswers = { ...answers, [qIdx]: selected };
|
fetch(`${API}/queue/status`, { headers: authHeaders() })
|
||||||
setAnswers(newAnswers);
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
const correctCount = questions.filter((q, i) => newAnswers[i] === q.correct).length;
|
if (!leavingRef.current && data.status === 'matched') {
|
||||||
const elapsed = Date.now() - startTime;
|
navigateToGame(data);
|
||||||
|
|
||||||
if (qIdx < STAGE_SIZE - 1) {
|
|
||||||
submitProgress(qIdx + 1, correctCount, false);
|
|
||||||
const next = qIdx + 1;
|
|
||||||
setQIdx(next);
|
|
||||||
setSelected(newAnswers[next] ?? null);
|
|
||||||
} else {
|
|
||||||
submittedRef.current = true;
|
|
||||||
submitProgress(STAGE_SIZE, correctCount, true, elapsed);
|
|
||||||
setPhase('waiting');
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBack() {
|
function navigateToGame(data) {
|
||||||
const newAnswers = selected !== null ? { ...answers, [qIdx]: selected } : answers;
|
clearInterval(pollRef.current);
|
||||||
setAnswers(newAnswers);
|
completedRef.current = true;
|
||||||
const prev = qIdx - 1;
|
navigate('/ranked-game', {
|
||||||
setQIdx(prev);
|
state: {
|
||||||
setSelected(newAnswers[prev] ?? null);
|
gameId: data.gameId,
|
||||||
}
|
questions: data.questions,
|
||||||
|
opponentName: data.opponent.username ?? data.opponent.displayName,
|
||||||
function handleJump(targetIdx) {
|
opponentAvatar: data.opponent.avatarUrl,
|
||||||
const newAnswers = selected !== null ? { ...answers, [qIdx]: selected } : answers;
|
myName,
|
||||||
setAnswers(newAnswers);
|
myAvatar,
|
||||||
setQIdx(targetIdx);
|
grade,
|
||||||
setSelected(newAnswers[targetIdx] ?? null);
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!VALID_GRADES.has(grade)) return <Navigate to="/dashboard" replace />;
|
if (!VALID_GRADES.has(grade)) return <Navigate to="/dashboard" replace />;
|
||||||
|
|
||||||
if (phase === 'finding')
|
return <FindingScreen elapsed={queueElapsed} onBack={() => navigate('/dashboard')} />;
|
||||||
return <FindingScreen elapsed={queueElapsed} onBack={() => navigate('/dashboard')} showForceMatch={showForceMatch} onForceMatch={forceMatch} />;
|
|
||||||
|
|
||||||
if (phase === 'intro')
|
|
||||||
return <StageIntro grade={grade} myName={myName} myAvatar={myAvatar} opponentName={opponentName} opponentAvatar={opponentAvatar} onNext={() => { setPhase('question'); pollGameStatus(); }} />;
|
|
||||||
|
|
||||||
if (phase === 'question')
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<QuestionScreen
|
|
||||||
question={questions[qIdx]} qIdx={qIdx} answers={answers} selected={selected}
|
|
||||||
onSelect={setSelected} onNext={handleNext} onBack={handleBack} onJump={handleJump}
|
|
||||||
opponentProgress={opponentProgress} opponentName={opponentName}
|
|
||||||
/>
|
|
||||||
{showDefeatOverlay && (
|
|
||||||
<DefeatOverlay opponentName={opponentName} onViewResults={() => { setShowDefeatOverlay(false); setPhase('complete'); }} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (phase === 'waiting')
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<WaitingScreen opponentName={opponentName} opponentProgress={opponentProgress} />
|
|
||||||
{showDefeatOverlay && (
|
|
||||||
<DefeatOverlay opponentName={opponentName} onViewResults={() => { setShowDefeatOverlay(false); setPhase('complete'); }} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (phase === 'complete')
|
|
||||||
return (
|
|
||||||
<CompleteScreen
|
|
||||||
won={playerWon} opponentName={opponentName} duration={duration}
|
|
||||||
eloChange={eloChange} onDashboard={() => navigate('/dashboard')}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useNavigate, useLocation, Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
const STAGE_SIZE = 11;
|
||||||
|
const API = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
|
const NAV_BTN = {
|
||||||
|
padding: '14px 40px', borderRadius: 999,
|
||||||
|
fontFamily: 'system-ui,sans-serif', fontSize: 17, fontWeight: 600,
|
||||||
|
cursor: 'pointer', border: '1.5px solid #b6d8f5',
|
||||||
|
background: '#e8f4fd', color: '#1a73e8',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FORFEIT_BTN = {
|
||||||
|
padding: '14px 28px', borderRadius: 999, fontFamily: 'system-ui,sans-serif',
|
||||||
|
fontSize: 14, fontWeight: 600, cursor: 'pointer',
|
||||||
|
border: '1.5px solid #e57373', background: '#fff', color: '#c62828',
|
||||||
|
};
|
||||||
|
|
||||||
|
function authHeaders() {
|
||||||
|
const token = sessionStorage.getItem('ranked_token');
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function OpponentProgressStrip({ progress, total, name, dced, disconnected }) {
|
||||||
|
const items = [];
|
||||||
|
for (let qi = 0; qi < total; qi++) {
|
||||||
|
const filled = qi < progress;
|
||||||
|
items.push(
|
||||||
|
<div key={`b-${qi}`} style={{ width: 30, height: 30, borderRadius: '50%', border: `2px solid ${filled ? '#e67e22' : '#ccc'}`, background: filled ? '#e67e22' : '#fff', color: filled ? '#fff' : '#aaa', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 700, fontFamily: 'system-ui,-apple-system,sans-serif', flexShrink: 0, padding: 0, boxSizing: 'border-box', transition: 'background 0.15s, border-color 0.15s' }}>{qi + 1}</div>
|
||||||
|
);
|
||||||
|
if (qi < total - 1) {
|
||||||
|
items.push(
|
||||||
|
<div key={`l-${qi}`} style={{ flex: 1, height: 2, minWidth: 2, background: filled ? '#e67e22' : '#ddd' }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 8, padding: '10px 0', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color: (dced || disconnected) ? '#c62828' : '#e67e22', textTransform: 'uppercase', letterSpacing: '0.05em', fontFamily: 'system-ui,sans-serif' }}>{name}</span>
|
||||||
|
{dced && <span style={{ fontSize: 10, fontWeight: 700, color: '#fff', background: '#c62828', borderRadius: 4, padding: '1px 5px', fontFamily: 'system-ui,sans-serif', letterSpacing: '0.04em' }}>DC'd</span>}
|
||||||
|
{!dced && disconnected && <span style={{ fontSize: 10, fontWeight: 700, color: '#fff', background: '#e57373', borderRadius: 4, padding: '1px 5px', fontFamily: 'system-ui,sans-serif', letterSpacing: '0.04em' }}>disconnected?</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'nowrap', width: '100%', opacity: (dced || disconnected) ? 0.5 : 1 }}>
|
||||||
|
{items}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressStrip({ total, current, answers, onJump }) {
|
||||||
|
const items = [];
|
||||||
|
for (let qi = 0; qi < total; qi++) {
|
||||||
|
const active = qi === current, answered = answers[qi] != null, filled = answered;
|
||||||
|
items.push(
|
||||||
|
<button key={`b-${qi}`} onClick={() => onJump && onJump(qi)} style={{ width: 36, height: 36, borderRadius: '50%', border: `2px solid ${filled || active ? '#1a73e8' : '#ccc'}`, background: filled ? '#1a73e8' : active ? '#e8f0fe' : '#fff', color: filled ? '#fff' : active ? '#1a73e8' : '#aaa', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, fontWeight: 700, fontFamily: 'system-ui,-apple-system,sans-serif', flexShrink: 0, cursor: 'pointer', padding: 0, boxSizing: 'border-box', transition: 'background 0.15s, border-color 0.15s' }}>{qi + 1}</button>
|
||||||
|
);
|
||||||
|
if (qi < total - 1) items.push(<div key={`l-${qi}`} style={{ flex: 1, height: 3, minWidth: 2, background: answers[qi] != null ? '#1a73e8' : '#ddd' }} />);
|
||||||
|
}
|
||||||
|
return <div style={{ display: 'flex', alignItems: 'center', flexWrap: 'nowrap', width: '100%' }}>{items}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayerChip({ name, avatar, reverse }) {
|
||||||
|
const pic = avatar
|
||||||
|
? <img src={avatar} alt="" style={{ width: 56, height: 56, borderRadius: '50%', flexShrink: 0 }} referrerPolicy="no-referrer" />
|
||||||
|
: <div style={{ width: 56, height: 56, borderRadius: '50%', background: '#1a73e8', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 22, fontWeight: 700, flexShrink: 0 }}>{name?.[0]?.toUpperCase() ?? '?'}</div>;
|
||||||
|
const label = <span style={{ fontSize: 17, fontWeight: 700, color: '#222', fontFamily: 'system-ui,sans-serif' }}>{name}</span>;
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: reverse ? 'row-reverse' : 'row', alignItems: 'center', gap: 12 }}>
|
||||||
|
{pic}{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StageIntro({ grade, myName, myAvatar, opponentName, opponentAvatar, onNext }) {
|
||||||
|
const [count, setCount] = useState(5);
|
||||||
|
const gradeNum = grade?.replace('G', '') ?? '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setCount(c => {
|
||||||
|
if (c <= 1) { clearInterval(id); onNext(); return 0; }
|
||||||
|
return c - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [onNext]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#fff', fontFamily: 'system-ui,sans-serif', gap: 28 }}>
|
||||||
|
<p style={{ fontSize: 11, color: '#999', letterSpacing: 3, textTransform: 'uppercase', margin: 0 }}>Ranked Match</p>
|
||||||
|
<h1 style={{ fontSize: 'clamp(30px,5vw,52px)', fontWeight: 800, color: '#111', margin: 0, textAlign: 'center' }}>EQAO Grade {gradeNum}</h1>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 20, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||||
|
<PlayerChip name={myName} avatar={myAvatar} reverse={false} />
|
||||||
|
<span style={{ fontSize: 18, fontWeight: 700, color: '#aaa', letterSpacing: 2 }}>vs</span>
|
||||||
|
<PlayerChip name={opponentName} avatar={opponentAvatar} reverse={true} />
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 14, color: '#aaa', margin: 0 }}>First to finish all {STAGE_SIZE} questions wins.</p>
|
||||||
|
<div style={{ fontSize: 'clamp(88px,18vw,144px)', fontWeight: 900, color: '#e53935', lineHeight: 1, fontVariantNumeric: 'tabular-nums', minWidth: 120, textAlign: 'center' }}>{count}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext, onBack, onJump, opponentProgress, opponentName, opponentDced, opponentConnected, onForfeit }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e) => {
|
||||||
|
const i = +e.key - 1;
|
||||||
|
if (i >= 0 && i < q.choices.length) { onSelect(i); return; }
|
||||||
|
if ((e.key === 'Enter' || e.key === 'ArrowRight') && qIdx < STAGE_SIZE - 1) onNext();
|
||||||
|
if (e.key === 'ArrowLeft' && qIdx > 0) onBack();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, [q.choices.length, qIdx, onSelect, onNext, onBack]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: '#cce8f4', minHeight: '100vh', fontFamily: 'system-ui,-apple-system,sans-serif' }}>
|
||||||
|
<div style={{ maxWidth: 760, margin: '0 auto', background: '#fff', minHeight: '100vh', display: 'flex', flexDirection: 'column', boxShadow: '0 0 40px rgba(0,60,120,0.10)' }}>
|
||||||
|
<div style={{ borderBottom: '1px solid #e8e8e8', padding: '20px 32px 16px' }}>
|
||||||
|
<p style={{ fontSize: 36, fontWeight: 700, color: '#333', margin: '0 0 14px 0', lineHeight: 1 }}>Question {qIdx + 1} of {STAGE_SIZE}</p>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color: '#1a73e8', textTransform: 'uppercase', letterSpacing: '0.05em', fontFamily: 'system-ui,sans-serif' }}>You</span>
|
||||||
|
<div style={{ flex: 1 }}><ProgressStrip total={STAGE_SIZE} current={qIdx} answers={answers} onJump={onJump} /></div>
|
||||||
|
</div>
|
||||||
|
<OpponentProgressStrip progress={opponentProgress} total={STAGE_SIZE} name={opponentName} dced={opponentDced} disconnected={!opponentConnected && !opponentDced} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, padding: '28px 32px 24px' }}>
|
||||||
|
<p style={{ fontSize: 24, color: '#111', lineHeight: 1.7, whiteSpace: 'pre-line', fontWeight: 500, margin: '0 0 28px 0' }}>{q.question}</p>
|
||||||
|
<div style={q.layout === 'grid' ? { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 } : { display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{q.choices.map((choice, i) => {
|
||||||
|
const sel = selected === i;
|
||||||
|
return (
|
||||||
|
<button key={i} onClick={() => onSelect(i)} style={{ minHeight: 64, padding: '18px 24px', background: sel ? '#e8f0fe' : '#f7f8fa', border: `2px solid ${sel ? '#1a73e8' : '#e0e0e0'}`, borderRadius: 12, fontSize: 20, color: sel ? '#1a73e8' : '#222', textAlign: 'left', cursor: 'pointer', fontWeight: sel ? 600 : 400, fontFamily: 'system-ui,-apple-system,sans-serif', lineHeight: 1.45, transition: 'background 0.12s, border-color 0.12s' }}>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 700, color: sel ? '#1a73e8' : '#aaa', marginRight: 20, opacity: 0.7 }}>{i + 1}</span>{choice}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ borderTop: '1px solid #e0e0e0', boxShadow: '0 -2px 8px rgba(0,0,0,0.06)', padding: '16px 32px', display: 'flex', justifyContent: 'space-between', gap: 14 }}>
|
||||||
|
<button style={FORFEIT_BTN} onClick={onForfeit}>Forfeit</button>
|
||||||
|
<div style={{ display: 'flex', gap: 14 }}>
|
||||||
|
{qIdx > 0 && <button style={NAV_BTN} onClick={onBack}>‹ Back</button>}
|
||||||
|
{qIdx === STAGE_SIZE - 1 ? (
|
||||||
|
<button style={{ padding: '14px 40px', borderRadius: 999, fontSize: 17, fontWeight: 700, cursor: 'pointer', border: 'none', fontFamily: 'system-ui,sans-serif', background: '#ff69b4', color: '#000', boxShadow: '0 0 6px #ff69b4aa' }} onClick={onNext}>Submit ★</button>
|
||||||
|
) : (
|
||||||
|
<button style={NAV_BTN} onClick={onNext}>Next ›</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DefeatOverlay({ opponentName, onViewResults }) {
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.55)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 2000 }}>
|
||||||
|
<div style={{ background: '#fff', borderRadius: 16, padding: '40px 48px', textAlign: 'center', maxWidth: 380, width: '90%', fontFamily: 'system-ui,sans-serif', boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }}>
|
||||||
|
<div style={{ fontSize: 64, marginBottom: 12 }}>💀</div>
|
||||||
|
<h1 style={{ fontSize: 32, fontWeight: 800, color: '#c62828', margin: '0 0 8px' }}>Defeated!</h1>
|
||||||
|
<p style={{ fontSize: 16, color: '#555', margin: '0 0 24px', lineHeight: 1.5 }}>{opponentName} finished before you.</p>
|
||||||
|
<button onClick={onViewResults} style={{ padding: '12px 36px', fontSize: 16, fontWeight: 600, background: '#1a73e8', color: '#fff', border: 'none', borderRadius: 999, cursor: 'pointer', fontFamily: 'system-ui,sans-serif' }}>See Results</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WaitingScreen({ opponentName, opponentProgress, opponentDced, opponentConnected, onForfeit }) {
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#fff', fontFamily: 'system-ui,sans-serif', gap: 24 }}>
|
||||||
|
{opponentDced
|
||||||
|
? <div style={{ fontSize: 48 }}>🔌</div>
|
||||||
|
: <div style={{ width: 48, height: 48, borderRadius: '50%', border: '3px solid #e0e0e0', borderTopColor: '#ff69b4', animation: 'spin 0.6s linear infinite' }} />
|
||||||
|
}
|
||||||
|
<h2 style={{ fontSize: 22, fontWeight: 600, color: opponentDced ? '#c62828' : '#333', margin: 0 }}>
|
||||||
|
{opponentDced ? `${opponentName} disconnected!` : `Waiting for ${opponentName}...`}
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: 15, color: '#888', margin: 0 }}>
|
||||||
|
{opponentDced ? 'Awarding victory...' : 'You finished! Now waiting for your opponent to complete.'}
|
||||||
|
</p>
|
||||||
|
<div style={{ width: '100%', maxWidth: 480, marginTop: 8 }}>
|
||||||
|
<OpponentProgressStrip progress={opponentProgress} total={STAGE_SIZE} name={opponentName} dced={opponentDced} disconnected={!opponentConnected && !opponentDced} />
|
||||||
|
</div>
|
||||||
|
<button onClick={onForfeit} style={{ marginTop: 16, ...FORFEIT_BTN }}>Forfeit & Leave</button>
|
||||||
|
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompleteScreen({ won, opponentName, duration, eloChange, opponentDced, youDced, onDashboard }) {
|
||||||
|
const subtitle = opponentDced && won
|
||||||
|
? `${opponentName} disconnected. Victory by default.`
|
||||||
|
: youDced && !won
|
||||||
|
? `You disconnected. ${opponentName} wins.`
|
||||||
|
: won
|
||||||
|
? `You beat ${opponentName}! Well played.`
|
||||||
|
: `${opponentName} beat you. Better luck next time.`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh', background: '#fff', fontFamily: 'system-ui,sans-serif' }}>
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '40px 32px', textAlign: 'center', gap: 20 }}>
|
||||||
|
<div style={{ fontSize: 72 }}>{won ? (opponentDced ? '🔌' : '🏆') : '💀'}</div>
|
||||||
|
<h1 style={{ fontSize: 36, fontWeight: 800, color: won ? '#1b8a2d' : '#c62828', margin: 0 }}>{won ? 'Victory!' : 'Defeated!'}</h1>
|
||||||
|
<p style={{ fontSize: 17, color: '#555', maxWidth: 400, margin: 0 }}>{subtitle}</p>
|
||||||
|
{duration > 0 && (
|
||||||
|
<div style={{ padding: '14px 40px', background: won ? '#f0faf0' : '#fef0f0', border: `2px solid ${won ? '#a5d6a7' : '#ef9a9a'}`, borderRadius: 12, fontSize: 24, fontWeight: 700, color: won ? '#1b8a2d' : '#c62828' }}>
|
||||||
|
⏱ {Math.floor(duration / 1000 / 60)}:{String(Math.floor(duration / 1000) % 60).padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{eloChange !== 0 && <div style={{ padding: '10px 24px', borderRadius: 8, fontSize: 18, fontWeight: 700, fontFamily: 'system-ui,sans-serif', color: eloChange > 0 ? '#1b8a2d' : '#c62828' }}>ELO {eloChange > 0 ? '+' : ''}{eloChange}</div>}
|
||||||
|
<button onClick={onDashboard} style={{ padding: '13px 40px', background: '#1a73e8', color: '#fff', border: 'none', borderRadius: 999, fontSize: 16, fontWeight: 600, cursor: 'pointer', fontFamily: 'system-ui,sans-serif' }}>Back to Dashboard</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RankedGame() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { state } = useLocation();
|
||||||
|
|
||||||
|
if (!state || !state.gameId || !state.questions) {
|
||||||
|
return <Navigate to="/dashboard" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { gameId, questions: initialQuestions, opponentName: initOppName, opponentAvatar: initOppAvatar, myName: initMyName, myAvatar: initMyAvatar, grade } = state;
|
||||||
|
|
||||||
|
const [phase, setPhase] = useState('intro');
|
||||||
|
const [questions] = useState(initialQuestions);
|
||||||
|
const [qIdx, setQIdx] = useState(0);
|
||||||
|
const [answers, setAnswers] = useState({});
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
const [opponentProgress, setOpponentProgress] = useState(0);
|
||||||
|
const [opponentName] = useState(initOppName);
|
||||||
|
const [opponentAvatar] = useState(initOppAvatar);
|
||||||
|
const [playerWon, setPlayerWon] = useState(null);
|
||||||
|
const [showDefeatOverlay, setShowDefeatOverlay] = useState(false);
|
||||||
|
const [startTime] = useState(() => Date.now());
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [eloChange, setEloChange] = useState(0);
|
||||||
|
const [opponentDced, setOpponentDced] = useState(false);
|
||||||
|
const [youDced, setYouDced] = useState(false);
|
||||||
|
const [opponentConnected, setOpponentConnected] = useState(true);
|
||||||
|
|
||||||
|
const pollRef = useRef(null);
|
||||||
|
const completedRef = useRef(false);
|
||||||
|
const submittedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => clearInterval(pollRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function submitProgress(currentQuestion, correctAnswers, finished, timeSpentMs) {
|
||||||
|
fetch(`${API}/game/progress`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
|
body: JSON.stringify({ gameId, currentQuestion, correctAnswers, finished, timeSpentMs }),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollGameStatus() {
|
||||||
|
pollRef.current = setInterval(() => {
|
||||||
|
if (completedRef.current) { clearInterval(pollRef.current); return; }
|
||||||
|
|
||||||
|
fetch(`${API}/game/status/${gameId}`, { headers: authHeaders() })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.opponentDced) setOpponentDced(true);
|
||||||
|
if (data.youDced) setYouDced(true);
|
||||||
|
if (data.opponentConnected === false) setOpponentConnected(false);
|
||||||
|
else if (data.opponentConnected === true && !data.opponentDced) setOpponentConnected(true);
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
const you = data.players.you;
|
||||||
|
const won = data.youWon === true;
|
||||||
|
completedRef.current = true;
|
||||||
|
setDuration(you.timeSpentMs || (Date.now() - startTime));
|
||||||
|
setEloChange(you.eloChange || 0);
|
||||||
|
setPlayerWon(won);
|
||||||
|
if (won) {
|
||||||
|
setPhase('complete');
|
||||||
|
} else {
|
||||||
|
setShowDefeatOverlay(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const opp = data.players.opponent;
|
||||||
|
setOpponentProgress(opp.currentQuestion + 1);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNext() {
|
||||||
|
const newAnswers = { ...answers, [qIdx]: selected };
|
||||||
|
setAnswers(newAnswers);
|
||||||
|
|
||||||
|
const correctCount = questions.filter((q, i) => newAnswers[i] === q.correct).length;
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
|
||||||
|
if (qIdx < STAGE_SIZE - 1) {
|
||||||
|
submitProgress(qIdx + 1, correctCount, false);
|
||||||
|
const next = qIdx + 1;
|
||||||
|
setQIdx(next);
|
||||||
|
setSelected(newAnswers[next] ?? null);
|
||||||
|
} else {
|
||||||
|
submittedRef.current = true;
|
||||||
|
submitProgress(STAGE_SIZE, correctCount, true, elapsed);
|
||||||
|
setPhase('waiting');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBack() {
|
||||||
|
const newAnswers = selected !== null ? { ...answers, [qIdx]: selected } : answers;
|
||||||
|
setAnswers(newAnswers);
|
||||||
|
const prev = qIdx - 1;
|
||||||
|
setQIdx(prev);
|
||||||
|
setSelected(newAnswers[prev] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleJump(targetIdx) {
|
||||||
|
const newAnswers = selected !== null ? { ...answers, [qIdx]: selected } : answers;
|
||||||
|
setAnswers(newAnswers);
|
||||||
|
setQIdx(targetIdx);
|
||||||
|
setSelected(newAnswers[targetIdx] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleForfeit() {
|
||||||
|
if (!window.confirm('Are you sure you want to forfeit? This counts as a loss.')) return;
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
completedRef.current = true;
|
||||||
|
try {
|
||||||
|
await fetch(`${API}/game/forfeit`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
|
body: JSON.stringify({ gameId }),
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
navigate('/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === 'intro')
|
||||||
|
return <StageIntro grade={grade} myName={initMyName} myAvatar={initMyAvatar} opponentName={opponentName} opponentAvatar={opponentAvatar} onNext={() => { setPhase('question'); pollGameStatus(); }} />;
|
||||||
|
|
||||||
|
if (phase === 'question')
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<QuestionScreen
|
||||||
|
question={questions[qIdx]} qIdx={qIdx} answers={answers} selected={selected}
|
||||||
|
onSelect={setSelected} onNext={handleNext} onBack={handleBack} onJump={handleJump}
|
||||||
|
opponentProgress={opponentProgress} opponentName={opponentName}
|
||||||
|
opponentDced={opponentDced} opponentConnected={opponentConnected}
|
||||||
|
onForfeit={handleForfeit}
|
||||||
|
/>
|
||||||
|
{showDefeatOverlay && (
|
||||||
|
<DefeatOverlay opponentName={opponentName} onViewResults={() => { setShowDefeatOverlay(false); setPhase('complete'); }} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (phase === 'waiting')
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<WaitingScreen opponentName={opponentName} opponentProgress={opponentProgress} opponentDced={opponentDced} opponentConnected={opponentConnected} onForfeit={handleForfeit} />
|
||||||
|
{showDefeatOverlay && (
|
||||||
|
<DefeatOverlay opponentName={opponentName} onViewResults={() => { setShowDefeatOverlay(false); setPhase('complete'); }} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (phase === 'complete')
|
||||||
|
return (
|
||||||
|
<CompleteScreen
|
||||||
|
won={playerWon} opponentName={opponentName} duration={duration}
|
||||||
|
eloChange={eloChange} opponentDced={opponentDced} youDced={youDced} onDashboard={() => navigate('/dashboard')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,14 +1,72 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTheme } from '../context/ThemeContext';
|
import { useTheme } from '../context/ThemeContext';
|
||||||
|
import UsernameModal from '../components/UsernameModal';
|
||||||
|
|
||||||
|
const API = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
|
const [token, setToken] = useState(null);
|
||||||
|
const [username, setUsername] = useState(null);
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState(null);
|
||||||
|
const [usernameModal, setUsernameModal] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tok = sessionStorage.getItem('ranked_token');
|
||||||
|
if (!tok) { navigate('/'); return; }
|
||||||
|
setToken(tok);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
fetch(`${API}/auth/me`, { headers: { Authorization: `Bearer ${token}` } })
|
||||||
|
.then(res => { if (res.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return null; } return res.json(); })
|
||||||
|
.then(data => { if (data) { setUsername(data.username ?? null); setAvatarUrl(data.avatarUrl ?? null); } })
|
||||||
|
.catch(() => {});
|
||||||
|
}, [token, navigate]);
|
||||||
|
|
||||||
|
async function handleFileSelect(e) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (file.size > 2 * 1024 * 1024) { alert('Image too large. Max 2MB.'); return; }
|
||||||
|
setUploading(true);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (event) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/me/avatar`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ image: event.target.result }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) setAvatarUrl(data.avatarUrl);
|
||||||
|
else alert(data.error || 'Upload failed');
|
||||||
|
} catch {
|
||||||
|
alert('Could not upload. Check your connection.');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="arena-bg"><div className="arena-grain" /></div>
|
<div className="arena-bg"><div className="arena-grain" /></div>
|
||||||
|
|
||||||
|
{usernameModal && (
|
||||||
|
<UsernameModal
|
||||||
|
token={token}
|
||||||
|
current={username}
|
||||||
|
onClose={() => setUsernameModal(false)}
|
||||||
|
onSave={(u) => setUsername(u)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<nav className="dash-nav">
|
<nav className="dash-nav">
|
||||||
<span className="dash-nav__logo">RANKED<span>EQAO</span></span>
|
<span className="dash-nav__logo">RANKED<span>EQAO</span></span>
|
||||||
<button className="settings-back" onClick={() => navigate('/dashboard')}>Back</button>
|
<button className="settings-back" onClick={() => navigate('/dashboard')}>Back</button>
|
||||||
@@ -19,6 +77,38 @@ export default function Settings() {
|
|||||||
<h1 className="settings-title">Settings</h1>
|
<h1 className="settings-title">Settings</h1>
|
||||||
|
|
||||||
<div className="settings-card">
|
<div className="settings-card">
|
||||||
|
<div className="settings-row">
|
||||||
|
<div className="settings-row__info">
|
||||||
|
<span className="settings-row__label">Profile Picture</span>
|
||||||
|
<span className="settings-row__desc">
|
||||||
|
{avatarUrl ? 'Click to change' : 'Upload a photo'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="settings-avatar" onClick={() => fileInputRef.current?.click()}>
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img className="settings-avatar__img" src={avatarUrl} alt="Avatar" referrerPolicy="no-referrer" />
|
||||||
|
) : (
|
||||||
|
<div className="settings-avatar__placeholder">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{uploading && <div className="settings-avatar__spinner" />}
|
||||||
|
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleFileSelect} style={{ display: 'none' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-row">
|
||||||
|
<div className="settings-row__info">
|
||||||
|
<span className="settings-row__label">Username</span>
|
||||||
|
<span className="settings-row__desc">
|
||||||
|
{username ? `@${username}` : 'Not set \u2014 choose a unique name'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button className="settings-row__action" onClick={() => setUsernameModal(true)}>
|
||||||
|
{username ? 'Change' : 'Set'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="settings-row">
|
<div className="settings-row">
|
||||||
<div className="settings-row__info">
|
<div className="settings-row__info">
|
||||||
<span className="settings-row__label">Theme</span>
|
<span className="settings-row__label">Theme</span>
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
Prisma DB: npx prisma studio
|
Prisma DB: npx prisma studio
|
||||||
Run: cd frontend && npm run dev
|
|
||||||
Express server: node src/index.js
|
Express server: node src/index.js
|
||||||
|
Run: cd frontend && npm run dev
|
||||||
|
|
||||||
KILL 3000: kill $(lsof -ti:3000)
|
KILL 3000: kill $(lsof -ti:3000)
|
||||||
|
|
||||||
push: git -c http.proxy=socks5://127.0.0.1:9149 push origin main
|
push: git -c http.proxy=socks5://127.0.0.1:9149 push origin main
|
||||||
pull: git -c http.proxy=socks5://127.0.0.1:9149 pull --rebase origin main
|
pull: git -c http.proxy=socks5://127.0.0.1:9149 pull --rebase origin main
|
||||||
|
|
||||||
push at school: git push origin main
|
|
||||||
pull at school: git pull --rebase origin main
|
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
"dev": "node --watch src/index.js",
|
"dev": "node --watch src/index.js",
|
||||||
|
"studio": "prisma studio",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Made the column `username` on table `User` required. This step will fail if there are existing NULL values in that column.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- Backfill: assign a unique username derived from email or googleId for rows with NULL username
|
||||||
|
UPDATE "User"
|
||||||
|
SET "username" = lower(regexp_replace(split_part("email", '@', 1), '[^a-zA-Z0-9_]', '', 'g'))
|
||||||
|
WHERE "username" IS NULL;
|
||||||
|
|
||||||
|
-- Ensure uniqueness by appending a suffix for any collisions
|
||||||
|
UPDATE "User" u
|
||||||
|
SET "username" = u."username" || '_' || substr(u."id", 1, 6)
|
||||||
|
WHERE (
|
||||||
|
SELECT COUNT(*) FROM "User" u2
|
||||||
|
WHERE u2."username" = u."username" AND u2."id" != u."id"
|
||||||
|
) > 0;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ALTER COLUMN "username" SET NOT NULL;
|
||||||
+15
-1
@@ -36,13 +36,14 @@ model User {
|
|||||||
googleId String @unique
|
googleId String @unique
|
||||||
email String @unique
|
email String @unique
|
||||||
displayName String
|
displayName String
|
||||||
username String? @unique
|
username String @unique
|
||||||
avatarUrl String?
|
avatarUrl String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
sessions GameSession[]
|
sessions GameSession[]
|
||||||
leaderboardEntries LeaderboardEntry[]
|
leaderboardEntries LeaderboardEntry[]
|
||||||
|
matchResults MatchResult[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Question {
|
model Question {
|
||||||
@@ -103,3 +104,16 @@ model LeaderboardEntry {
|
|||||||
|
|
||||||
@@unique([userId, grade])
|
@@unique([userId, grade])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model MatchResult {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
grade Grade
|
||||||
|
won Boolean
|
||||||
|
eloChange Int
|
||||||
|
eloAfter Int
|
||||||
|
timeSpentMs Int?
|
||||||
|
playedAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
}
|
||||||
|
|||||||
+18
-8
@@ -3,19 +3,29 @@ const { Strategy: GoogleStrategy } = require('passport-google-oauth20');
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
|
|
||||||
async function generateUniqueUsername(base) {
|
function deriveBase(email) {
|
||||||
const candidate = base.toLowerCase();
|
const local = email.split('@')[0];
|
||||||
const taken = await db.user.findFirst({ where: { username: candidate } });
|
const sanitized = local
|
||||||
if (!taken) return candidate;
|
.replace(/\./g, '_')
|
||||||
|
.replace(/[^a-zA-Z0-9_]/g, '')
|
||||||
|
.slice(0, 15)
|
||||||
|
.toLowerCase();
|
||||||
|
return sanitized || 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateUniqueUsername(email) {
|
||||||
|
const base = deriveBase(email);
|
||||||
|
const taken = await db.user.findFirst({ where: { username: base } });
|
||||||
|
if (!taken) return base;
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
while (attempts < 20) {
|
while (attempts < 20) {
|
||||||
const suffix = String(Math.floor(1000 + Math.random() * 9000));
|
const suffix = String(Math.floor(1000 + Math.random() * 9000));
|
||||||
const username = `${candidate}${suffix}`;
|
const username = `${base}_${suffix}`;
|
||||||
const collision = await db.user.findFirst({ where: { username } });
|
const collision = await db.user.findFirst({ where: { username } });
|
||||||
if (!collision) return username;
|
if (!collision) return username;
|
||||||
attempts++;
|
attempts++;
|
||||||
}
|
}
|
||||||
return `${candidate}${Date.now()}`;
|
return `${base}_${Date.now()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
passport.use(
|
passport.use(
|
||||||
@@ -31,7 +41,7 @@ passport.use(
|
|||||||
let user = await db.user.findUnique({ where: { googleId: profile.id } });
|
let user = await db.user.findUnique({ where: { googleId: profile.id } });
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
const username = await generateUniqueUsername(email.split('@')[0]);
|
const username = await generateUniqueUsername(email);
|
||||||
user = await db.user.create({
|
user = await db.user.create({
|
||||||
data: {
|
data: {
|
||||||
googleId: profile.id,
|
googleId: profile.id,
|
||||||
@@ -51,7 +61,7 @@ passport.use(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!user.username) {
|
if (!user.username) {
|
||||||
const username = await generateUniqueUsername((user.email ?? '').split('@')[0]);
|
const username = await generateUniqueUsername(user.email ?? '');
|
||||||
user = await db.user.update({ where: { id: user.id }, data: { username } });
|
user = await db.user.update({ where: { id: user.id }, data: { username } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+199
-76
@@ -2,6 +2,8 @@ require('dotenv').config();
|
|||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const passport = require('./auth/google');
|
const passport = require('./auth/google');
|
||||||
const authMiddleware = require('./middleware/auth');
|
const authMiddleware = require('./middleware/auth');
|
||||||
@@ -14,8 +16,6 @@ const VALID_GRADES = new Set(['G3', 'G6', 'G9']);
|
|||||||
const USERNAME_RE = /^[a-zA-Z0-9_]{3,20}$/;
|
const USERNAME_RE = /^[a-zA-Z0-9_]{3,20}$/;
|
||||||
const STAGE_SIZE = 11;
|
const STAGE_SIZE = 11;
|
||||||
const MIN_GAME_MS = 8000; // fastest a human can answer 11 questions
|
const MIN_GAME_MS = 8000; // fastest a human can answer 11 questions
|
||||||
const DC_TIMEOUT_MS = 8000; // no heartbeat for this long → disconnected
|
|
||||||
const DC_GRACE_MS = 15000; // don't check until this long after game start
|
|
||||||
|
|
||||||
// ── In-memory matchmaking ──────────────────────────────────────
|
// ── In-memory matchmaking ──────────────────────────────────────
|
||||||
const queue = {}; // { userId: { grade, joinedAt, displayName, avatarUrl } }
|
const queue = {}; // { userId: { grade, joinedAt, displayName, avatarUrl } }
|
||||||
@@ -43,38 +43,61 @@ async function completeGame(game, forcedWinnerId = null) {
|
|||||||
if (game.completing || game.status !== 'active') return;
|
if (game.completing || game.status !== 'active') return;
|
||||||
game.completing = true;
|
game.completing = true;
|
||||||
|
|
||||||
|
if (game.abandonTimeout) {
|
||||||
|
clearTimeout(game.abandonTimeout);
|
||||||
|
game.abandonTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
const players = Object.keys(game.players);
|
const players = Object.keys(game.players);
|
||||||
const winnerId = forcedWinnerId ?? computeGameResult(game);
|
const winnerId = forcedWinnerId ?? computeGameResult(game);
|
||||||
game.winner = winnerId;
|
game.winner = winnerId;
|
||||||
game.completedAt = Date.now();
|
game.completedAt = Date.now();
|
||||||
|
|
||||||
|
// Snapshot both ELOs before any writes
|
||||||
|
const eloSnapshot = {};
|
||||||
|
for (const uid of players) {
|
||||||
|
const entry = await db.leaderboardEntry.findUnique({
|
||||||
|
where: { userId_grade: { userId: uid, grade: game.grade } },
|
||||||
|
});
|
||||||
|
eloSnapshot[uid] = entry?.elo ?? DEFAULT_ELO;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loserId = players.find(id => id !== winnerId);
|
||||||
|
const { winnerChange, loserChange } = calculateElo(eloSnapshot[winnerId], eloSnapshot[loserId]);
|
||||||
|
|
||||||
for (const userId of players) {
|
for (const userId of players) {
|
||||||
const p = game.players[userId];
|
const p = game.players[userId];
|
||||||
const won = userId === winnerId;
|
const isWinner = userId === winnerId;
|
||||||
|
const change = isWinner ? winnerChange : loserChange;
|
||||||
const currentEntry = await db.leaderboardEntry.findUnique({
|
const newElo = eloSnapshot[userId] + change;
|
||||||
where: { userId_grade: { userId, grade: game.grade } },
|
|
||||||
});
|
|
||||||
const currentElo = currentEntry?.elo ?? DEFAULT_ELO;
|
|
||||||
const otherId = players.find(id => id !== userId);
|
|
||||||
const otherEntry = await db.leaderboardEntry.findUnique({
|
|
||||||
where: { userId_grade: { userId: otherId, grade: game.grade } },
|
|
||||||
});
|
|
||||||
const opponentElo = otherEntry?.elo ?? DEFAULT_ELO;
|
|
||||||
const result = calculateElo(currentElo, opponentElo, won);
|
|
||||||
|
|
||||||
await upsertLeaderboardEntry(userId, game.grade, {
|
await upsertLeaderboardEntry(userId, game.grade, {
|
||||||
elo: result.newElo,
|
elo: newElo,
|
||||||
bestTime: p.timeSpentMs ?? undefined,
|
bestTime: p.completedQuiz ? (p.timeSpentMs ?? undefined) : undefined,
|
||||||
correctAnswers: p.correctAnswers,
|
correctAnswers: p.correctAnswers,
|
||||||
});
|
});
|
||||||
|
|
||||||
p.eloChange = result.change;
|
p.eloChange = change;
|
||||||
p.newElo = result.newElo;
|
p.newElo = newElo;
|
||||||
|
|
||||||
delete playerGames[userId];
|
delete playerGames[userId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record per-player match results for dashboard history
|
||||||
|
await Promise.all(players.map(userId => {
|
||||||
|
const p = game.players[userId];
|
||||||
|
return db.matchResult.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
grade: game.grade,
|
||||||
|
won: userId === winnerId,
|
||||||
|
eloChange: p.eloChange,
|
||||||
|
eloAfter: p.newElo,
|
||||||
|
timeSpentMs: p.timeSpentMs ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
// Set completed only after ELO is written so polls see final eloChange
|
// Set completed only after ELO is written so polls see final eloChange
|
||||||
game.status = 'completed';
|
game.status = 'completed';
|
||||||
|
|
||||||
@@ -83,56 +106,13 @@ async function completeGame(game, forcedWinnerId = null) {
|
|||||||
}, 30000);
|
}, 30000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disconnect detection — runs every 3s
|
// Cleanup stale queue entries (30s no heartbeat) and old completed games every 30s
|
||||||
setInterval(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const game of Object.values(activeGames)) {
|
|
||||||
if (game.status !== 'active' || game.completing) continue;
|
|
||||||
if (now - game.startedAt < DC_GRACE_MS) continue;
|
|
||||||
|
|
||||||
const players = Object.keys(game.players);
|
|
||||||
const gone = players.filter(id => {
|
|
||||||
const p = game.players[id];
|
|
||||||
return !p.finished && now - (p.lastSeen ?? game.startedAt) > DC_TIMEOUT_MS;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (gone.length === 2) {
|
|
||||||
// Both gone — cancel, no ELO change
|
|
||||||
game.completing = true;
|
|
||||||
game.status = 'completed';
|
|
||||||
game.winner = null;
|
|
||||||
game.completedAt = now;
|
|
||||||
for (const id of players) delete playerGames[id];
|
|
||||||
setTimeout(() => { delete activeGames[game.id]; }, 30000);
|
|
||||||
} else if (gone.length === 1) {
|
|
||||||
const winnerId = players.find(id => id !== gone[0]);
|
|
||||||
completeGame(game, winnerId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
// Cleanup stale queue entries (5min timeout) and hard-expired games every 30s
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [userId, q] of Object.entries(queue)) {
|
for (const [userId, q] of Object.entries(queue)) {
|
||||||
if (now - q.joinedAt > 300000) delete queue[userId];
|
if (now - q.lastSeen > 30000) delete queue[userId];
|
||||||
}
|
}
|
||||||
for (const [id, game] of Object.entries(activeGames)) {
|
for (const [id, game] of Object.entries(activeGames)) {
|
||||||
if (game.status === 'active' && now - game.startedAt > 300000) {
|
|
||||||
const players = Object.keys(game.players);
|
|
||||||
const p0 = game.players[players[0]];
|
|
||||||
const p1 = game.players[players[1]];
|
|
||||||
if (p0.finished || p1.finished) {
|
|
||||||
completeGame(game);
|
|
||||||
} else {
|
|
||||||
game.completing = true;
|
|
||||||
game.status = 'completed';
|
|
||||||
game.winner = null;
|
|
||||||
game.completedAt = now;
|
|
||||||
for (const userId of players) delete playerGames[userId];
|
|
||||||
delete activeGames[id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (game.status === 'completed' && game.completedAt && now - game.completedAt > 60000) {
|
if (game.status === 'completed' && game.completedAt && now - game.completedAt > 60000) {
|
||||||
delete activeGames[id];
|
delete activeGames[id];
|
||||||
}
|
}
|
||||||
@@ -141,10 +121,14 @@ setInterval(() => {
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json({ limit: '3mb' }));
|
||||||
app.use(cors({ origin: process.env.FRONTEND_URL, credentials: true }));
|
app.use(cors({ origin: process.env.FRONTEND_URL, credentials: true }));
|
||||||
app.use(passport.initialize());
|
app.use(passport.initialize());
|
||||||
|
|
||||||
|
const UPLOADS_DIR = path.join(__dirname, '..', 'uploads');
|
||||||
|
if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||||
|
app.use('/uploads', express.static(UPLOADS_DIR));
|
||||||
|
|
||||||
app.get('/auth/google', passport.authenticate('google', {
|
app.get('/auth/google', passport.authenticate('google', {
|
||||||
scope: ['profile', 'email'],
|
scope: ['profile', 'email'],
|
||||||
session: false,
|
session: false,
|
||||||
@@ -177,14 +161,15 @@ app.get('/auth/me', authMiddleware, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/leaderboard', async (req, res) => {
|
app.get('/leaderboard', async (req, res) => {
|
||||||
const { grade } = req.query;
|
const { grade, type = 'time' } = req.query;
|
||||||
if (!VALID_GRADES.has(grade)) {
|
if (!VALID_GRADES.has(grade)) {
|
||||||
return res.status(400).json({ error: 'grade must be one of G3, G6, G9' });
|
return res.status(400).json({ error: 'grade must be one of G3, G6, G9' });
|
||||||
}
|
}
|
||||||
|
const byElo = type === 'elo';
|
||||||
try {
|
try {
|
||||||
const entries = await db.leaderboardEntry.findMany({
|
const entries = await db.leaderboardEntry.findMany({
|
||||||
where: { grade, bestTime: { not: null } },
|
where: byElo ? { grade, gamesPlayed: { gt: 0 } } : { grade, bestTime: { not: null } },
|
||||||
orderBy: { bestTime: 'asc' },
|
orderBy: byElo ? { elo: 'desc' } : { bestTime: 'asc' },
|
||||||
take: 10,
|
take: 10,
|
||||||
include: {
|
include: {
|
||||||
user: { select: { displayName: true, username: true, avatarUrl: true } },
|
user: { select: { displayName: true, username: true, avatarUrl: true } },
|
||||||
@@ -196,6 +181,7 @@ app.get('/leaderboard', async (req, res) => {
|
|||||||
username: e.user.username,
|
username: e.user.username,
|
||||||
avatarUrl: e.user.avatarUrl,
|
avatarUrl: e.user.avatarUrl,
|
||||||
bestTime: e.bestTime,
|
bestTime: e.bestTime,
|
||||||
|
elo: e.elo,
|
||||||
})));
|
})));
|
||||||
} catch {
|
} catch {
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
@@ -206,11 +192,22 @@ app.get('/me/stats', authMiddleware, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const entries = await Promise.all(
|
const entries = await Promise.all(
|
||||||
['G3', 'G6', 'G9'].map(async grade => {
|
['G3', 'G6', 'G9'].map(async grade => {
|
||||||
const [eloResult, timeResult] = await Promise.all([
|
const [eloResult, timeResult, totalPlayers, recentMatches] = await Promise.all([
|
||||||
getUserRank(req.user.userId, grade, 'elo'),
|
getUserRank(req.user.userId, grade, 'elo'),
|
||||||
getUserRank(req.user.userId, grade, 'bestTime'),
|
getUserRank(req.user.userId, grade, 'bestTime'),
|
||||||
|
db.leaderboardEntry.count({ where: { grade, gamesPlayed: { gt: 0 } } }),
|
||||||
|
db.matchResult.findMany({
|
||||||
|
where: { userId: req.user.userId, grade },
|
||||||
|
orderBy: { playedAt: 'desc' },
|
||||||
|
take: 20,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
const entry = eloResult?.entry ?? null;
|
const entry = eloResult?.entry ?? null;
|
||||||
|
let winStreak = 0;
|
||||||
|
for (const m of recentMatches) {
|
||||||
|
if (m.won) winStreak++;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
grade,
|
grade,
|
||||||
elo: entry?.elo ?? 800,
|
elo: entry?.elo ?? 800,
|
||||||
@@ -219,6 +216,8 @@ app.get('/me/stats', authMiddleware, async (req, res) => {
|
|||||||
totalCorrect: entry?.totalCorrect ?? 0,
|
totalCorrect: entry?.totalCorrect ?? 0,
|
||||||
eloRank: eloResult?.rank ?? null,
|
eloRank: eloResult?.rank ?? null,
|
||||||
timeRank: timeResult?.rank ?? null,
|
timeRank: timeResult?.rank ?? null,
|
||||||
|
totalPlayers,
|
||||||
|
winStreak,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -228,6 +227,23 @@ app.get('/me/stats', authMiddleware, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/me/recent-matches', authMiddleware, async (req, res) => {
|
||||||
|
const { grade } = req.query;
|
||||||
|
if (grade && !VALID_GRADES.has(grade)) {
|
||||||
|
return res.status(400).json({ error: 'invalid grade' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const matches = await db.matchResult.findMany({
|
||||||
|
where: { userId: req.user.userId, ...(grade ? { grade } : {}) },
|
||||||
|
orderBy: { playedAt: 'desc' },
|
||||||
|
take: 5,
|
||||||
|
});
|
||||||
|
res.json(matches);
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/game/complete', authMiddleware, async (req, res) => {
|
app.post('/game/complete', authMiddleware, async (req, res) => {
|
||||||
const { grade, correctAnswers, totalQuestions, timeSpentMs, isRanked, won } = req.body;
|
const { grade, correctAnswers, totalQuestions, timeSpentMs, isRanked, won } = req.body;
|
||||||
|
|
||||||
@@ -270,7 +286,7 @@ app.put('/me/username', authMiddleware, async (req, res) => {
|
|||||||
const lower = username.toLowerCase();
|
const lower = username.toLowerCase();
|
||||||
try {
|
try {
|
||||||
const conflict = await db.user.findFirst({
|
const conflict = await db.user.findFirst({
|
||||||
where: { username: lower, NOT: { id: req.user.userId } },
|
where: { username: lower, id: { not: req.user.userId } },
|
||||||
});
|
});
|
||||||
if (conflict) return res.status(409).json({ error: 'taken' });
|
if (conflict) return res.status(409).json({ error: 'taken' });
|
||||||
const updated = await db.user.update({
|
const updated = await db.user.update({
|
||||||
@@ -278,11 +294,40 @@ app.put('/me/username', authMiddleware, async (req, res) => {
|
|||||||
data: { username: lower },
|
data: { username: lower },
|
||||||
});
|
});
|
||||||
res.json({ username: updated.username });
|
res.json({ username: updated.username });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('[me/username]', err);
|
||||||
|
if (err.code === 'P2002') return res.status(409).json({ error: 'taken' });
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const VALID_IMAGE_RE = /^data:image\/(png|jpeg|jpg|gif|webp);base64,/;
|
||||||
|
|
||||||
|
app.post('/me/avatar', authMiddleware, async (req, res) => {
|
||||||
|
const { image } = req.body;
|
||||||
|
if (!image || typeof image !== 'string' || !VALID_IMAGE_RE.test(image)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid image data' });
|
||||||
|
}
|
||||||
|
const base64Data = image.replace(/^data:image\/\w+;base64,/, '');
|
||||||
|
const buf = Buffer.from(base64Data, 'base64');
|
||||||
|
if (buf.length > 2 * 1024 * 1024) {
|
||||||
|
return res.status(400).json({ error: 'Image too large (max 2MB)' });
|
||||||
|
}
|
||||||
|
const mime = image.match(/^data:(image\/\w+);/)[1];
|
||||||
|
const ext = mime.split('/')[1].replace('jpeg', 'jpg');
|
||||||
|
const filename = `avatar_${req.user.userId}_${Date.now()}.${ext}`;
|
||||||
|
const filepath = path.join(UPLOADS_DIR, filename);
|
||||||
|
try {
|
||||||
|
await fs.promises.writeFile(filepath, buf);
|
||||||
|
const avatarUrl = `${req.protocol}://${req.get('host')}/uploads/${filename}`;
|
||||||
|
await db.user.update({ where: { id: req.user.userId }, data: { avatarUrl } });
|
||||||
|
res.json({ avatarUrl });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[me/avatar]', err);
|
||||||
|
res.status(500).json({ error: 'Failed to save image' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/questions', authMiddleware, (req, res) => {
|
app.get('/questions', authMiddleware, (req, res) => {
|
||||||
const { grade } = req.query;
|
const { grade } = req.query;
|
||||||
if (!VALID_GRADES.has(grade)) {
|
if (!VALID_GRADES.has(grade)) {
|
||||||
@@ -409,7 +454,7 @@ app.post('/queue/join', authMiddleware, async (req, res) => {
|
|||||||
return res.json({ status: 'matched', gameId });
|
return res.json({ status: 'matched', gameId });
|
||||||
}
|
}
|
||||||
|
|
||||||
queue[req.user.userId] = { grade, joinedAt: Date.now(), displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl };
|
queue[req.user.userId] = { grade, joinedAt: Date.now(), displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl, lastSeen: Date.now() };
|
||||||
res.json({ status: 'waiting' });
|
res.json({ status: 'waiting' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[queue/join] error:', err);
|
console.error('[queue/join] error:', err);
|
||||||
@@ -417,8 +462,29 @@ app.post('/queue/join', authMiddleware, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/queue/leave', authMiddleware, (req, res) => {
|
app.delete('/queue/leave', authMiddleware, async (req, res) => {
|
||||||
delete queue[req.user.userId];
|
const userId = req.user.userId;
|
||||||
|
delete queue[userId];
|
||||||
|
|
||||||
|
// If the player was matched into a game during the cancel window, forfeit it
|
||||||
|
// immediately so the opponent isn't stuck waiting for a ghost.
|
||||||
|
const gameId = playerGames[userId];
|
||||||
|
if (gameId) {
|
||||||
|
const game = activeGames[gameId];
|
||||||
|
if (game && game.status === 'active') {
|
||||||
|
const player = game.players[userId];
|
||||||
|
if (player && !player.finished) {
|
||||||
|
const opponentId = Object.keys(game.players).find(id => id !== userId);
|
||||||
|
// Leave timeSpentMs null so completeGame doesn't record a bestTime for
|
||||||
|
// someone who cancelled before the game even started.
|
||||||
|
player.correctAnswers = 0;
|
||||||
|
player.finished = true;
|
||||||
|
player.finishedAt = Date.now();
|
||||||
|
await completeGame(game, opponentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -441,6 +507,8 @@ app.get('/queue/status', authMiddleware, async (req, res) => {
|
|||||||
return res.json({ status: 'not_in_queue' });
|
return res.json({ status: 'not_in_queue' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
game.players[req.user.userId].lastSeen = Date.now();
|
||||||
|
|
||||||
const opponentId = Object.keys(game.players).find(id => id !== req.user.userId);
|
const opponentId = Object.keys(game.players).find(id => id !== req.user.userId);
|
||||||
const opponent = game.players[opponentId];
|
const opponent = game.players[opponentId];
|
||||||
|
|
||||||
@@ -464,6 +532,7 @@ app.get('/queue/status', authMiddleware, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (queue[req.user.userId]) {
|
if (queue[req.user.userId]) {
|
||||||
|
queue[req.user.userId].lastSeen = Date.now();
|
||||||
return res.json({ status: 'waiting' });
|
return res.json({ status: 'waiting' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,6 +541,36 @@ app.get('/queue/status', authMiddleware, async (req, res) => {
|
|||||||
|
|
||||||
// ── Game management ────────────────────────────────────────────
|
// ── Game management ────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.post('/game/forfeit', authMiddleware, async (req, res) => {
|
||||||
|
const { gameId } = req.body;
|
||||||
|
if (!gameId) return res.status(400).json({ error: 'gameId required' });
|
||||||
|
|
||||||
|
const game = activeGames[gameId];
|
||||||
|
if (!game || game.status !== 'active') {
|
||||||
|
return res.status(404).json({ error: 'Game not found or already completed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const player = game.players[req.user.userId];
|
||||||
|
if (!player) {
|
||||||
|
return res.status(403).json({ error: 'Not a participant in this game' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.finished) return res.json({ success: true });
|
||||||
|
|
||||||
|
const elapsed = Date.now() - game.startedAt;
|
||||||
|
player.timeSpentMs = Math.max(MIN_GAME_MS, elapsed + 5000);
|
||||||
|
player.correctAnswers = 0;
|
||||||
|
player.finished = true;
|
||||||
|
player.finishedAt = Date.now();
|
||||||
|
|
||||||
|
game.abandonTimeout = null;
|
||||||
|
|
||||||
|
const opponentId = Object.keys(game.players).find(id => id !== req.user.userId);
|
||||||
|
completeGame(game, opponentId);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/game/progress', authMiddleware, (req, res) => {
|
app.post('/game/progress', authMiddleware, (req, res) => {
|
||||||
const { gameId, currentQuestion, correctAnswers, finished, timeSpentMs } = req.body;
|
const { gameId, currentQuestion, correctAnswers, finished, timeSpentMs } = req.body;
|
||||||
const game = activeGames[gameId];
|
const game = activeGames[gameId];
|
||||||
@@ -485,10 +584,10 @@ app.post('/game/progress', authMiddleware, (req, res) => {
|
|||||||
return res.status(403).json({ error: 'Not a participant in this game' });
|
return res.status(403).json({ error: 'Not a participant in this game' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (player.finished) return res.json({ success: true }); // idempotent
|
|
||||||
|
|
||||||
player.lastSeen = Date.now();
|
player.lastSeen = Date.now();
|
||||||
|
|
||||||
|
if (player.finished) return res.json({ success: true }); // idempotent
|
||||||
|
|
||||||
if (currentQuestion !== undefined) {
|
if (currentQuestion !== undefined) {
|
||||||
player.currentQuestion = Math.max(0, Math.min(STAGE_SIZE, Math.floor(Number(currentQuestion))));
|
player.currentQuestion = Math.max(0, Math.min(STAGE_SIZE, Math.floor(Number(currentQuestion))));
|
||||||
}
|
}
|
||||||
@@ -503,9 +602,18 @@ app.post('/game/progress', authMiddleware, (req, res) => {
|
|||||||
player.timeSpentMs = Math.max(MIN_GAME_MS, Math.min(claimed, elapsed + 5000));
|
player.timeSpentMs = Math.max(MIN_GAME_MS, Math.min(claimed, elapsed + 5000));
|
||||||
player.finished = true;
|
player.finished = true;
|
||||||
player.finishedAt = Date.now();
|
player.finishedAt = Date.now();
|
||||||
|
player.completedQuiz = true;
|
||||||
|
|
||||||
const winner = computeGameResult(game);
|
const winner = computeGameResult(game);
|
||||||
if (winner) completeGame(game);
|
if (winner) {
|
||||||
|
completeGame(game);
|
||||||
|
} else {
|
||||||
|
// Opponent hasn't finished — award win to this player after 5 minutes if game still active
|
||||||
|
const finishedUserId = req.user.userId;
|
||||||
|
game.abandonTimeout = setTimeout(() => {
|
||||||
|
if (game.status === 'active') completeGame(game, finishedUserId);
|
||||||
|
}, 5 * 60 * 1000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
@@ -527,12 +635,27 @@ app.get('/game/status/:gameId', authMiddleware, async (req, res) => {
|
|||||||
const opponentId = Object.keys(game.players).find(id => id !== req.user.userId);
|
const opponentId = Object.keys(game.players).find(id => id !== req.user.userId);
|
||||||
const opponent = game.players[opponentId];
|
const opponent = game.players[opponentId];
|
||||||
|
|
||||||
|
// Detect opponent disconnect — award win after 20s of inactivity
|
||||||
|
const opponentInactive = opponent.lastSeen && Date.now() - opponent.lastSeen > 20000;
|
||||||
|
if (game.status === 'active' && !player.finished && opponentInactive) {
|
||||||
|
game.disconnectedPlayer = opponentId;
|
||||||
|
await completeGame(game, req.user.userId);
|
||||||
|
}
|
||||||
|
|
||||||
const opponentUser = await db.user.findUnique({ where: { id: opponentId }, select: { username: true, displayName: true } });
|
const opponentUser = await db.user.findUnique({ where: { id: opponentId }, select: { username: true, displayName: true } });
|
||||||
const opponentName = opponentUser?.username ?? opponentUser?.displayName ?? opponent.displayName;
|
const opponentName = opponentUser?.username ?? opponentUser?.displayName ?? opponent.displayName;
|
||||||
|
|
||||||
|
const opponentDced = game.disconnectedPlayer === opponentId;
|
||||||
|
const youDced = game.disconnectedPlayer === req.user.userId;
|
||||||
|
// Show DC badge early (8s inactivity) before the game officially ends at 20s
|
||||||
|
const opponentConnected = !opponentDced && (!opponent.lastSeen || Date.now() - opponent.lastSeen < 8000);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
status: game.status,
|
status: game.status,
|
||||||
youWon: game.winner !== null && game.winner === req.user.userId,
|
youWon: game.winner !== null && game.winner === req.user.userId,
|
||||||
|
opponentDced,
|
||||||
|
youDced,
|
||||||
|
opponentConnected,
|
||||||
players: {
|
players: {
|
||||||
you: {
|
you: {
|
||||||
currentQuestion: player.currentQuestion,
|
currentQuestion: player.currentQuestion,
|
||||||
|
|||||||
+6
-6
@@ -4,13 +4,13 @@ const DIFF_STEP = 30;
|
|||||||
const MAX_CHANGE = 30;
|
const MAX_CHANGE = 30;
|
||||||
const MIN_CHANGE = 1;
|
const MIN_CHANGE = 1;
|
||||||
|
|
||||||
// Custom ELO: base ±10, adjusted ±1 for every 30-point gap, clamped [1, 30].
|
// Calculate ELO change for a completed match.
|
||||||
// Underdog who wins gains more; favourite who wins gains less.
|
// Magnitude is derived once from the winner's perspective so both sides
|
||||||
function calculateElo(yourElo, opponentElo, won) {
|
// move by the same amount (winner gains X, loser loses X).
|
||||||
const diff = opponentElo - yourElo;
|
function calculateElo(winnerElo, loserElo) {
|
||||||
|
const diff = loserElo - winnerElo; // positive = underdog won, negative = favourite won
|
||||||
const magnitude = Math.max(MIN_CHANGE, Math.min(MAX_CHANGE, BASE_CHANGE + Math.floor(diff / DIFF_STEP)));
|
const magnitude = Math.max(MIN_CHANGE, Math.min(MAX_CHANGE, BASE_CHANGE + Math.floor(diff / DIFF_STEP)));
|
||||||
const change = won ? magnitude : -magnitude;
|
return { winnerChange: magnitude, loserChange: -magnitude };
|
||||||
return { newElo: yourElo + change, change };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { calculateElo, DEFAULT_ELO };
|
module.exports = { calculateElo, DEFAULT_ELO };
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ async function upsertLeaderboardEntry(userId, grade, { elo, bestTime, correctAns
|
|||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
...(elo !== undefined && { elo }),
|
...(elo !== undefined && { elo }),
|
||||||
...(bestTime !== undefined && existing?.bestTime == null || bestTime < (existing?.bestTime ?? Infinity)
|
...(bestTime !== undefined && (existing?.bestTime == null || bestTime < existing.bestTime)
|
||||||
? { bestTime }
|
? { bestTime }
|
||||||
: {}),
|
: {}),
|
||||||
gamesPlayed: { increment: 1 },
|
gamesPlayed: { increment: 1 },
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Reference in New Issue
Block a user