diff --git a/.DS_Store b/.DS_Store index f105b0a..105922f 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/frontend/package.json b/frontend/package.json index fe2831e..d6392eb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --host", "build": "vite build", "preview": "vite preview" }, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8ed0a16..2a660b6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4,6 +4,7 @@ import Login from './pages/Login'; import Dashboard from './pages/Dashboard'; import Game from './pages/Game'; import Queue from './pages/Queue'; +import RankedGame from './pages/RankedGame'; import Settings from './pages/Settings'; function getValidStoredToken() { @@ -38,6 +39,7 @@ export default function App() { : } /> : } /> : } /> + : } /> : } /> diff --git a/frontend/src/components/UsernameModal.jsx b/frontend/src/components/UsernameModal.jsx new file mode 100644 index 0000000..8f1936e --- /dev/null +++ b/frontend/src/components/UsernameModal.jsx @@ -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 ( +
+
e.stopPropagation()}> +

Set username

+

3–20 chars · letters, numbers, underscores

+ { setValue(e.target.value); setError(null); }} + onKeyDown={e => e.key === 'Enter' && handleSave()} + placeholder="e.g. player_one" + maxLength={20} + /> + {error &&

{error}

} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index e55af57..d0aecca 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -20,9 +20,9 @@ --silver: #94a3b8; --platinum: #f5c518; - --text: #e8eaf6; - --text-muted: #4b5270; - --text-dim: #6b7491; + --text: #eceef8; + --text-muted: #7a85a8; + --text-dim: #9ba5c4; --font-display: 'Russo One', sans-serif; --font-ui: 'Barlow Condensed', sans-serif; @@ -595,16 +595,551 @@ a { color: inherit; text-decoration: none; } /* Page wrapper */ .dash-page { 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; } /* 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 { text-align: center; padding: 48px 0 36px; width: 100%; - max-width: 740px; + max-width: 820px; } .dash-hero__eyebrow { @@ -670,6 +1205,8 @@ a { color: inherit; text-decoration: none; } gap: 16px; } + + .dash-cta__btn { display: flex; align-items: center; @@ -727,6 +1264,7 @@ a { color: inherit; text-decoration: none; } margin-bottom: 40px; } + .stat-card { display: flex; flex-direction: column; @@ -776,6 +1314,7 @@ a { color: inherit; text-decoration: none; } margin-bottom: 32px; } + .dash-section__hd { display: flex; align-items: center; @@ -984,6 +1523,38 @@ a { color: inherit; text-decoration: none; } 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 { padding: 12px 16px; display: flex; @@ -1027,6 +1598,7 @@ a { color: inherit; text-decoration: none; } color: var(--text-muted); } + /* Error banner */ .dash-error-banner { display: flex; @@ -1061,6 +1633,8 @@ a { color: inherit; text-decoration: none; } .dash-error-banner__dismiss:hover { opacity: 1; } + + /* Loading skeleton */ .skel-pill { background: var(--surface-2); @@ -1290,6 +1864,86 @@ a { color: inherit; text-decoration: none; } 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 { position: relative; width: 48px; @@ -1360,40 +2014,6 @@ a { color: inherit; text-decoration: none; } 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 ───────────────────────────────────────── */ .uname-overlay { position: fixed; @@ -1514,21 +2134,47 @@ a { color: inherit; text-decoration: none; } 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 ───────────────────────────────────────────── */ +@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) { .dash-nav { padding: 0 16px; } + .dash-page { padding-left: 16px; padding-right: 16px; } .profile-dropdown__trigger .dash-nav__name { display: none; } - .dash-cta__btn { padding: 16px 40px; } - .dash-stats { gap: 8px; } - .stat-card { padding: 18px 10px; } - .stat-card__value { font-size: clamp(22px, 6vw, 32px); } + .db-stats { grid-template-columns: repeat(2, 1fr); } + .db-stat__val { font-size: clamp(22px, 6vw, 30px); } + .db-btn { font-size: 15px; padding: 15px 16px; } .lb-row { padding: 10px 14px; gap: 10px; } .lb-row--current { padding-left: 12px; } + .db-hero__namerow { flex-direction: column; gap: 8px; } } @media (max-width: 480px) { .grade-badges { gap: 8px; } .grade-badge { padding: 12px 16px; } .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; } } \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index ae1d085..a5efd66 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import ProfileDropdown from '../components/ProfileDropdown'; @@ -10,12 +10,29 @@ const GRADES = [ { 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) { - try { - return JSON.parse(atob(token.split('.')[1])); - } catch { - return null; - } + try { return JSON.parse(atob(token.split('.')[1])); } + catch { return null; } } function fmtTime(ms) { @@ -24,63 +41,134 @@ function fmtTime(ms) { 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 ( -
- {label} - - {value} - + + {tier.name} + + ); +} + +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 ( +
+
+ {tier.name} + {pct}% + {nextTier?.name} +
+
+
+
); } -const RANK_STYLES = [ - { 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 }) { +function LeaderboardTable({ entries, loading, highlightRank, tab }) { if (loading) { return ( -
- {[...Array(5)].map((_, i) => ( -
+
+ {[...Array(LB_SLOTS)].map((_, i) => ( +
))}
); } - if (!entries.length) { - const gradeLabel = GRADES.find(g => g.id === grade)?.label ?? grade; - return ( -
- -

No times recorded yet for {gradeLabel}

-

Complete a quiz to appear on the board

-
- ); - } + const padded = [ + ...entries, + ...[...Array(Math.max(0, LB_SLOTS - entries.length))].map((_, i) => ({ + rank: entries.length + i + 1, + ghost: true, + })), + ]; return (
- Rank + # Player - Best Time + {tab === 'elo' ? 'ELO' : 'Best Time'}
- {entries.map(entry => { + {padded.map(entry => { + if (entry.ghost) { + return ( +
+ {entry.rank} +
+
+ +
+ {tab === 'elo' ? '—' : '—:——'} +
+ ); + } const isCurrent = highlightRank !== null && entry.rank === highlightRank; const rankStyle = entry.rank <= 3 ? RANK_STYLES[entry.rank - 1] : null; const playerLabel = entry.username ? `@${entry.username}` : entry.displayName; const fallbackLetter = (entry.username ?? entry.displayName)?.[0]?.toUpperCase() ?? '?'; return (
- 3 ? ' lb-rank--num' : ''}`} - style={rankStyle ?? undefined} - > + 3 ? ' lb-rank--num' : ''}`} style={rankStyle ?? undefined}> {entry.rank}
@@ -91,7 +179,9 @@ function LeaderboardTable({ entries, loading, highlightRank, grade }) { {playerLabel} {isCurrent && YOU}
- {fmtTime(entry.bestTime)} + + {tab === 'elo' ? (entry.elo?.toLocaleString() ?? '—') : fmtTime(entry.bestTime)} +
); })} @@ -99,63 +189,47 @@ function LeaderboardTable({ entries, loading, highlightRank, grade }) { ); } -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; - 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 ( -
-
e.stopPropagation()}> -

Set username

-

3–20 chars · letters, numbers, underscores

- { setValue(e.target.value); setError(null); }} - onKeyDown={e => e.key === 'Enter' && handleSave()} - placeholder="e.g. player_one" - maxLength={20} - /> - {error &&

{error}

} -
- - -
+function MatchHistory({ matches, loading }) { + if (loading) { + return ( +
+ {[0,1,2].map(i =>
)}
+ ); + } + if (!matches.length) { + return ( +
+
⚔️
+ No ranked matches yet + Queue up to start climbing +
+ ); + } + return ( +
+ {matches.map((m, i) => { + const sign = m.eloChange >= 0 ? '+' : ''; + return ( +
+
+ {m.won ? 'W' : 'L'} +
+
+ {m.eloAfter.toLocaleString()} ELO + {m.timeSpentMs != null && ( + {fmtTime(m.timeSpentMs)} + )} +
+
+ = 0 ? ' match-elo-delta--pos' : ' match-elo-delta--neg'}`}> + {sign}{m.eloChange} + + {fmtRelative(m.playedAt)} +
+
+ ); + })}
); } @@ -166,25 +240,17 @@ function LoadingSkeleton() {
-
-
-
-
- {[0, 1, 2].map(i => ( -
- ))} +
+
+
+
+
+ {[0,1,2,3].map(i =>
)}
-
-
-
-
-
- {[0, 1, 2].map(i => ( -
- ))} +
@@ -197,20 +263,21 @@ export default function Dashboard() { const [jwtPayload, setJwtPayload] = useState(null); const [user, setUser] = useState(null); const [username, setUsername] = useState(null); - const [usernameModal, setUsernameModal] = useState(false); const [stats, setStats] = useState([]); const [leaderboard, setLeaderboard] = useState([]); + const [matches, setMatches] = useState([]); const [selectedGrade, setSelectedGrade] = useState('G3'); + const [lbTab, setLbTab] = useState('elo'); const [loading, setLoading] = useState(true); const [lbLoading, setLbLoading] = useState(false); + const [matchLoading, setMatchLoading] = useState(false); const [error, setError] = useState(null); - const profileElo = stats.find(e => e.grade === selectedGrade)?.elo ?? null; + const [statsReady, setStatsReady] = useState(false); - // ── Auth init ── useEffect(() => { - const params = new URLSearchParams(window.location.search); + const params = new URLSearchParams(window.location.search); const urlToken = params.get('token'); - let tok = urlToken; + let tok = urlToken; if (urlToken) { sessionStorage.setItem('ranked_token', urlToken); history.replaceState(null, '', window.location.pathname); @@ -222,183 +289,251 @@ export default function Dashboard() { setJwtPayload(decodeJwt(tok)); }, [navigate]); - // ── Fetch user + stats ── useEffect(() => { if (!token) return; const headers = { Authorization: `Bearer ${token}` }; - Promise.all([ - fetch(`${API}/auth/me`, { headers }), - fetch(`${API}/me/stats`, { headers }), - ]) + Promise.all([fetch(`${API}/auth/me`, { headers }), fetch(`${API}/me/stats`, { headers })]) .then(async ([meRes, statsRes]) => { - if (meRes.status === 401) { - sessionStorage.removeItem('ranked_token'); - setLoading(false); - navigate('/'); - return; - } + if (meRes.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return; } const [meData, statsData] = await Promise.all([meRes.json(), statsRes.json()]); setUser(meData); setUsername(meData.username ?? null); setStats(statsData.entries ?? []); setLoading(false); + requestAnimationFrame(() => setStatsReady(true)); }) - .catch(() => { - setError('Could not reach the server. Check your connection and refresh.'); - setLoading(false); - }); + .catch(() => { setError('Could not reach the server.'); setLoading(false); }); }, [token, navigate]); - // ── Fetch leaderboard ── useEffect(() => { if (!token) return; setLbLoading(true); - fetch(`${API}/leaderboard?grade=${selectedGrade}`) - .then(res => res.json()) - .then(data => setLeaderboard(Array.isArray(data) ? data : [])) - .catch(() => setLeaderboard([])) + fetch(`${API}/leaderboard?grade=${selectedGrade}&type=${lbTab}`) + .then(r => r.json()) + .then(d => setLeaderboard(Array.isArray(d) ? d : [])) + .catch(() => setLeaderboard([])) .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]); - const signOut = () => { - sessionStorage.removeItem('ranked_token'); - navigate('/'); - }; + const signOut = () => { sessionStorage.removeItem('ranked_token'); navigate('/'); }; 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 firstName = displayName.split(' ')[0]; + const avatarUrl = user?.avatarUrl ?? jwtPayload?.picture ?? null; + 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 displayName = user?.displayName ?? jwtPayload?.name ?? 'Player'; - const firstName = displayName.split(' ')[0]; - const avatarUrl = user?.avatarUrl ?? jwtPayload?.picture ?? null; - const initial = displayName[0]?.toUpperCase() ?? '?'; + 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 ; + const heroLabel = username ?? firstName; + + const casualLabel = gamesPlayed === 0 ? 'Play First Game' : 'Casual'; + return ( <>
- {usernameModal && ( - setUsernameModal(false)} - onSave={(u) => setUsername(u)} - /> - )} - - {/* ── Nav ── */}
+
- {/* ── Hero ── */} -
-

{username ?? firstName}

-
- {GRADES.map(g => ( - - ))} + {error && ( +
+ {error} + +
+ )} + + {/* ── Hero ── */} +
+

{getGreeting()},

+
+

{heroLabel}

+ +
+ + {winStreak >= 2 && ( +
+ 🔥 + {winStreak} win streak +
+ )} + + {topPct && ( +

+ Top {topPct}% in Grade {selectedGrade.replace('G', '')} +

+ )} + +
+ {GRADES.map(g => ( + + ))} +
+
+ + {/* ── CTA buttons ── */} +
+ +
-
- {/* ── CTA Buttons ── */} -
- - -
- - {/* ── Error banner ── */} - {error && ( -
- {error} - -
- )} - - {/* ── Stats row ── */} -
- - - -
- - {/* ── Leaderboard ── */} -
-
-

Leaderboard

- Best Time -
-
- -
-
- - {/* ── Recent Activity ── */} -
-
-

Recent Activity

-
-
-
- 🎮 -

No games played yet

-

Complete a game to see your activity here

+ {/* ── ELO + Tier progress ── */} +
+
+
+ + {animatedElo.toLocaleString()} + + ELO + {eloRank && ( + + #{eloRank} + {totalPlayers > 0 && / {totalPlayers}} + + )} +
+
+ +
-
+ {/* ── Stats strip ── */} +
+ {[ + { label: 'Best Time', value: fmtTime(currentEntry?.bestTime), accent: true }, + { label: 'Games', value: animatedGames }, + { label: 'Correct', value: currentEntry?.totalCorrect ?? 0 }, + { label: 'Time Rank', value: currentEntry?.timeRank != null ? `#${currentEntry.timeRank}` : '—' }, + ].map(s => ( +
+ + {s.value} + + {s.label} +
+ ))} +
+ + {/* ── Recent Matches + Leaderboard side by side ── */} +
+ + {/* Recent Matches */} +
+
+ Recent Matches + {winStreak >= 2 && ( + + 🔥 {winStreak} streak + + )} +
+
+ +
+
+ + {/* Leaderboard */} +
+
+ Leaderboard +
+ + +
+
+
+ +
+
+ +
+ + {/* ── All-grades overview ── */} +
+
+ All Grades +
+
+ {GRADES.map(g => { + const e = stats.find(s => s.grade === g.id); + const t = getTier(e?.elo ?? 800); + return ( +
setSelectedGrade(g.id)} + > +
+ {g.label} + + {t.name} + +
+
+ + {e?.elo?.toLocaleString() ?? '800'} + + ELO +
+
+ {fmtTime(e?.bestTime)} + best +
+
+ {e?.gamesPlayed ?? 0} + games +
+
+ ); + })} +
+
+ +
); diff --git a/frontend/src/pages/Game.jsx b/frontend/src/pages/Game.jsx index f2cfd36..cd155e2 100644 --- a/frontend/src/pages/Game.jsx +++ b/frontend/src/pages/Game.jsx @@ -63,6 +63,12 @@ const NAV_BTN = { 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' }; // ── 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(() => { const handler = (e) => { const i = +e.key - 1; @@ -147,15 +153,18 @@ function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext
-
- {qIdx > 0 && } - {qIdx === STAGE_SIZE - 1 ? ( - - ) : ( - - )} +
+ +
+ {qIdx > 0 && } + {qIdx === STAGE_SIZE - 1 ? ( + + ) : ( + + )} +
@@ -256,22 +265,7 @@ function StageResult({ stage, wrongCount, questions, answers: initialAnswers, on ); } -function CompleteScreen({ duration, grade, 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 })); - }, []); - +function CompleteScreen({ duration, isNewBest, onDashboard }) { return (
@@ -281,9 +275,9 @@ function CompleteScreen({ duration, grade, onDashboard }) {
⏱ {fmtDuration(duration)}
- {pbState?.updated && ( -
- ★ New Personal Best! + {isNewBest && ( +
+ 🏆 New Personal Best!
)}
); if (phase === 'intro') return { if (stage === 1) { startTimer(); startSession(); } setPhase('question'); }} />; - if (phase === 'question') return ; + if (phase === 'question') return ; if (phase === 'result') return ; - if (phase === 'complete') return navigate('/dashboard')} />; + if (phase === 'complete') return navigate('/dashboard')} />; return null; } diff --git a/frontend/src/pages/Queue.jsx b/frontend/src/pages/Queue.jsx index 76ee863..a64b773 100644 --- a/frontend/src/pages/Queue.jsx +++ b/frontend/src/pages/Queue.jsx @@ -2,31 +2,14 @@ import { useState, useEffect, useRef } from 'react'; import { useNavigate, useLocation, Navigate } from 'react-router-dom'; const VALID_GRADES = new Set(['G3', 'G6', 'G9']); - -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', -}; -function OpponentBar({ progress, total, name }) { - const pct = (progress / total) * 100; - return ( -
- {name} -
-
-
- {Math.min(Math.round(progress), total)}/{total} -
- ); +function authHeaders() { + const token = sessionStorage.getItem('ranked_token'); + return token ? { Authorization: `Bearer ${token}` } : {}; } -function FindingScreen({ elapsed, onBack, onForceMatch, showForceMatch }) { +function FindingScreen({ elapsed, onBack }) { const [dots, setDots] = useState(''); useEffect(() => { const t = setInterval(() => setDots(d => d.length >= 3 ? '' : d + '.'), 400); return () => clearInterval(t); }, []); const minutes = Math.floor(elapsed / 60); @@ -40,165 +23,11 @@ function FindingScreen({ elapsed, onBack, onForceMatch, showForceMatch }) { Time elapsed: {minutes}:{String(seconds).padStart(2, '0')}
- {showForceMatch && ( - - )}
); } -function PlayerChip({ name, avatar, reverse }) { - const pic = avatar - ? - :
{name?.[0]?.toUpperCase() ?? '?'}
; - const label = {name}; - return ( -
- {pic}{label} -
- ); -} - -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 ( -
-

Ranked Match

-

EQAO Grade {gradeNum}

-
- - vs - -
-

First to finish all {STAGE_SIZE} questions wins.

-
{count}
-
- ); -} - -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( - - ); - if (qi < total - 1) items.push(
); - } - return
{items}
; -} - -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 ( -
-
-
-

Question {qIdx + 1} of {STAGE_SIZE}

-
- You -
-
- -
-
-

{q.question}

-
- {q.choices.map((choice, i) => { - const sel = selected === i; - return ( - - ); - })} -
-
-
- {qIdx > 0 && } - {qIdx === STAGE_SIZE - 1 ? ( - - ) : ( - - )} -
-
-
- ); -} - -function DefeatOverlay({ opponentName, onViewResults }) { - return ( -
-
-
💀
-

Defeated!

-

{opponentName} finished before you.

- -
-
- ); -} - -function WaitingScreen({ opponentName, opponentProgress }) { - return ( -
-
-

Waiting for {opponentName}...

-

You finished! Now waiting for your opponent to complete.

-
- -
- -
- ); -} - -function CompleteScreen({ won, opponentName, duration, eloChange, onDashboard }) { - return ( -
-
-
{won ? '🏆' : '💀'}
-

{won ? 'Victory!' : 'Defeated!'}

-

{won ? `You beat ${opponentName}! Well played.` : `${opponentName} beat you. Better luck next time.`}

-
- ⏱ {Math.floor(duration / 1000 / 60)}:{String(Math.floor(duration / 1000) % 60).padStart(2, '0')} -
- {eloChange !== 0 &&
0 ? '#1b8a2d' : '#c62828' }}>ELO {eloChange > 0 ? '+' : ''}{eloChange}
} - -
-
- ); -} - -function authHeaders() { - const token = sessionStorage.getItem('ranked_token'); - return token ? { Authorization: `Bearer ${token}` } : {}; -} - export default function Queue() { const navigate = useNavigate(); const { state } = useLocation(); @@ -206,73 +35,19 @@ export default function Queue() { const [myName, setMyName] = useState(''); 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 [showForceMatch, setShowForceMatch] = useState(false); - const gameIdRef = useRef(null); const pollRef = useRef(null); - const completedRef = useRef(false); - const submittedRef = useRef(false); const joinedRef = useRef(false); + const completedRef = useRef(false); const leaveTimerRef = useRef(null); + const leavingRef = useRef(false); - // Elapsed timer while finding + // Elapsed timer useEffect(() => { - if (phase !== 'finding') return; const id = setInterval(() => setQueueElapsed(e => e + 1), 1000); 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 useEffect(() => { @@ -288,43 +63,43 @@ export default function Queue() { // Join queue on mount 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) { clearTimeout(leaveTimerRef.current); 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`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify({ grade }), }) .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(); }) .then(data => { - console.log('[queue] join response:', JSON.stringify(data)); - if (!data || cancelled) return; + if (!data || leavingRef.current) return; joinedRef.current = true; if (data.status === 'waiting') { pollQueue(); } else if (data.status === 'matched') { - fetchMatchData(data.gameId); + fetchMatchData(); } }) - .catch(e => console.log('[queue] join error:', e)); + .catch(() => {}); return () => { - cancelled = true; + leavingRef.current = true; clearInterval(pollRef.current); - if (joinedRef.current && !gameIdRef.current && !completedRef.current) { + if (joinedRef.current && !completedRef.current) { leaveTimerRef.current = setTimeout(() => { fetch(`${API}/queue/leave`, { method: 'DELETE', headers: authHeaders() }).catch(() => {}); - }, 300); + }, 0); } }; }, [grade, navigate]); @@ -333,21 +108,15 @@ export default function Queue() { pollRef.current = setInterval(() => { fetch(`${API}/queue/status`, { headers: authHeaders() }) .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(); }) .then(data => { - if (!data) return; + if (!data || leavingRef.current) return; if (data.status === 'matched') { - console.log('[queue] poll found match!'); clearInterval(pollRef.current); - gameIdRef.current = data.gameId; - setQuestions(data.questions); - setOpponentName(data.opponent.username ?? data.opponent.displayName); - setOpponentAvatar(data.opponent.avatarUrl); - setPhase('intro'); + navigateToGame(data); } else if (data.status === 'not_in_queue') { - console.log('[queue] poll got not_in_queue, rejoining'); clearInterval(pollRef.current); fetch(`${API}/queue/join`, { method: 'POST', @@ -355,151 +124,49 @@ export default function Queue() { body: JSON.stringify({ grade }), }) .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(); }) .then(d => { - console.log('[queue] rejoin response:', JSON.stringify(d)); - if (!d) return; + if (!d || leavingRef.current) return; if (d.status === 'waiting') pollQueue(); - else if (d.status === 'matched') fetchMatchData(d.gameId); + else if (d.status === 'matched') navigateToGame(d); }) .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); } - 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 fetchMatchData() { + fetch(`${API}/queue/status`, { headers: authHeaders() }) + .then(r => r.json()) + .then(data => { + if (!leavingRef.current && data.status === 'matched') { + navigateToGame(data); + } + }) + .catch(() => {}); } - 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); + function navigateToGame(data) { + clearInterval(pollRef.current); + completedRef.current = true; + navigate('/ranked-game', { + state: { + gameId: data.gameId, + questions: data.questions, + opponentName: data.opponent.username ?? data.opponent.displayName, + opponentAvatar: data.opponent.avatarUrl, + myName, + myAvatar, + grade, + }, + }); } if (!VALID_GRADES.has(grade)) return ; - if (phase === 'finding') - return navigate('/dashboard')} showForceMatch={showForceMatch} onForceMatch={forceMatch} />; - - if (phase === 'intro') - return { setPhase('question'); pollGameStatus(); }} />; - - if (phase === 'question') - return ( - <> - - {showDefeatOverlay && ( - { setShowDefeatOverlay(false); setPhase('complete'); }} /> - )} - - ); - - if (phase === 'waiting') - return ( - <> - - {showDefeatOverlay && ( - { setShowDefeatOverlay(false); setPhase('complete'); }} /> - )} - - ); - - if (phase === 'complete') - return ( - navigate('/dashboard')} - /> - ); - - return null; + return navigate('/dashboard')} />; } diff --git a/frontend/src/pages/RankedGame.jsx b/frontend/src/pages/RankedGame.jsx new file mode 100644 index 0000000..f3058ef --- /dev/null +++ b/frontend/src/pages/RankedGame.jsx @@ -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( +
{qi + 1}
+ ); + if (qi < total - 1) { + items.push( +
+ ); + } + } + return ( +
+
+ {name} + {dced && DC'd} + {!dced && disconnected && disconnected?} +
+
+ {items} +
+
+ ); +} + +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( + + ); + if (qi < total - 1) items.push(
); + } + return
{items}
; +} + +function PlayerChip({ name, avatar, reverse }) { + const pic = avatar + ? + :
{name?.[0]?.toUpperCase() ?? '?'}
; + const label = {name}; + return ( +
+ {pic}{label} +
+ ); +} + +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 ( +
+

Ranked Match

+

EQAO Grade {gradeNum}

+
+ + vs + +
+

First to finish all {STAGE_SIZE} questions wins.

+
{count}
+
+ ); +} + +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 ( +
+
+
+

Question {qIdx + 1} of {STAGE_SIZE}

+
+ You +
+
+ +
+
+

{q.question}

+
+ {q.choices.map((choice, i) => { + const sel = selected === i; + return ( + + ); + })} +
+
+
+ +
+ {qIdx > 0 && } + {qIdx === STAGE_SIZE - 1 ? ( + + ) : ( + + )} +
+
+
+
+ ); +} + +function DefeatOverlay({ opponentName, onViewResults }) { + return ( +
+
+
💀
+

Defeated!

+

{opponentName} finished before you.

+ +
+
+ ); +} + +function WaitingScreen({ opponentName, opponentProgress, opponentDced, opponentConnected, onForfeit }) { + return ( +
+ {opponentDced + ?
🔌
+ :
+ } +

+ {opponentDced ? `${opponentName} disconnected!` : `Waiting for ${opponentName}...`} +

+

+ {opponentDced ? 'Awarding victory...' : 'You finished! Now waiting for your opponent to complete.'} +

+
+ +
+ + +
+ ); +} + +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 ( +
+
+
{won ? (opponentDced ? '🔌' : '🏆') : '💀'}
+

{won ? 'Victory!' : 'Defeated!'}

+

{subtitle}

+ {duration > 0 && ( +
+ ⏱ {Math.floor(duration / 1000 / 60)}:{String(Math.floor(duration / 1000) % 60).padStart(2, '0')} +
+ )} + {eloChange !== 0 &&
0 ? '#1b8a2d' : '#c62828' }}>ELO {eloChange > 0 ? '+' : ''}{eloChange}
} + +
+
+ ); +} + +export default function RankedGame() { + const navigate = useNavigate(); + const { state } = useLocation(); + + if (!state || !state.gameId || !state.questions) { + return ; + } + + 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 { setPhase('question'); pollGameStatus(); }} />; + + if (phase === 'question') + return ( + <> + + {showDefeatOverlay && ( + { setShowDefeatOverlay(false); setPhase('complete'); }} /> + )} + + ); + + if (phase === 'waiting') + return ( + <> + + {showDefeatOverlay && ( + { setShowDefeatOverlay(false); setPhase('complete'); }} /> + )} + + ); + + if (phase === 'complete') + return ( + navigate('/dashboard')} + /> + ); + + return null; +} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 6d59387..008a3f5 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -1,14 +1,72 @@ +import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTheme } from '../context/ThemeContext'; +import UsernameModal from '../components/UsernameModal'; + +const API = import.meta.env.VITE_API_URL; export default function Settings() { const navigate = useNavigate(); 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 ( <>
+ {usernameModal && ( + setUsernameModal(false)} + onSave={(u) => setUsername(u)} + /> + )} +