finished many ui changes + polishing dopamine hits
This commit is contained in:
@@ -1,142 +0,0 @@
|
|||||||
# Ranked EQAO — Claude Code Guide
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
A ranked quiz app based on EQAO (Ontario standardized tests).
|
|
||||||
Users sign in with Google, answer questions by grade and subject,
|
|
||||||
and compete on leaderboards.
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
- **Runtime:** Node.js + Express
|
|
||||||
- **Database:** PostgreSQL via Prisma ORM
|
|
||||||
- **Auth:** Google OAuth 2.0 (Passport.js) + JWT
|
|
||||||
- **AI:** Gemini API (passage + question generation for English; answer checking)
|
|
||||||
- **Frontend:** React (Vite) — `frontend/` directory
|
|
||||||
|
|
||||||
## Grades
|
|
||||||
Only three grades are supported: **3, 6, 9**
|
|
||||||
- In the schema these are the `Grade` enum: `G3`, `G6`, `G9`
|
|
||||||
- Never use raw integers for grade — always use the enum
|
|
||||||
|
|
||||||
## Subjects
|
|
||||||
Two sections per quiz: `MATH` and `ENGLISH` (reading + writing combined)
|
|
||||||
- The Prisma `Subject` enum currently has `MATH`, `READING`, `WRITING` — these remain as-is for DB storage
|
|
||||||
- In the UI and game logic, treat READING + WRITING as a single **English** section
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
```
|
|
||||||
/
|
|
||||||
├── prisma/
|
|
||||||
│ └── schema.prisma # All DB models
|
|
||||||
├── src/
|
|
||||||
│ ├── db.js # Prisma singleton
|
|
||||||
│ ├── auth/
|
|
||||||
│ │ └── google.js # Google OAuth + JWT issuing
|
|
||||||
│ ├── middleware/
|
|
||||||
│ │ └── auth.js # JWT verification middleware
|
|
||||||
│ └── services/
|
|
||||||
│ └── leaderboard.js # Leaderboard upsert + query logic
|
|
||||||
├── frontend/
|
|
||||||
│ └── src/
|
|
||||||
│ ├── pages/
|
|
||||||
│ │ ├── Login.jsx # Pre-login landing page
|
|
||||||
│ │ ├── Dashboard.jsx # Post-login dashboard (to be built)
|
|
||||||
│ │ └── MathGame.jsx # SCRAPPED — delete this file
|
|
||||||
│ ├── App.jsx
|
|
||||||
│ ├── index.css
|
|
||||||
│ └── main.jsx
|
|
||||||
├── .env # Never commit this
|
|
||||||
├── CLAUDE.md
|
|
||||||
└── package.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
| Variable | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `DATABASE_URL` | PostgreSQL connection string |
|
|
||||||
| `GOOGLE_CLIENT_ID` | Google OAuth app client ID |
|
|
||||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth app client secret |
|
|
||||||
| `GOOGLE_CALLBACK_URL` | OAuth redirect URI |
|
|
||||||
| `JWT_SECRET` | Secret for signing JWTs |
|
|
||||||
| `GEMINI_API_KEY` | Gemini API key for English question/passage generation and answer checking |
|
|
||||||
| `PORT` | Express server port (default 3000) |
|
|
||||||
| `FRONTEND_URL` | Frontend origin for CORS |
|
|
||||||
|
|
||||||
## Key Conventions
|
|
||||||
- Always use `Grade` enum (`G3`, `G6`, `G9`) — never raw numbers
|
|
||||||
- Always use `Subject` enum (`MATH`, `READING`, `WRITING`)
|
|
||||||
- All routes that touch user data require the `auth.js` JWT middleware
|
|
||||||
- Leaderboard is scoped per `[userId, grade, subject]` — unique constraint enforced in DB
|
|
||||||
- Sessions track full answer history via `UserAnswer` for future analytics
|
|
||||||
|
|
||||||
## Database Commands
|
|
||||||
```bash
|
|
||||||
npx prisma migrate dev --name <migration_name> # new migration
|
|
||||||
npx prisma generate # regenerate client after schema change
|
|
||||||
npx prisma studio # visual DB browser
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Frontend Pages
|
|
||||||
|
|
||||||
### Login.jsx (Pre-login Landing Page) — KEEP, DO NOT ADD DEBUG BUTTON
|
|
||||||
- The current pre-login landing page and Google sign-in button are good — preserve this design
|
|
||||||
- The page will eventually be a longer scrollable page with a top navigation bar linking to info sections and other useful tabs (TBD before release)
|
|
||||||
- **DO NOT add any debug/bypass button that skips Google login** — all such buttons must be removed if found anywhere in the codebase
|
|
||||||
- For now, leave the landing page as-is; extended layout/nav is a pre-release task
|
|
||||||
|
|
||||||
### MathGame.jsx — SCRAPPED, DELETE THIS FILE
|
|
||||||
- `frontend/src/pages/MathGame.jsx` is fully scrapped — delete it
|
|
||||||
- Remove all routes, imports, and links pointing to MathGame anywhere in the codebase
|
|
||||||
- The actual EQAO quiz experience will be built fresh (see Quiz UI section below)
|
|
||||||
|
|
||||||
### Dashboard.jsx (Post-login) — TO BE BUILT
|
|
||||||
- Replaces the current "Coming Soon" placeholder
|
|
||||||
- Should be a rich, well-designed dashboard with multiple sections/widgets, for example:
|
|
||||||
- User greeting with avatar
|
|
||||||
- Stats summary (games played, best scores, rank per grade)
|
|
||||||
- Leaderboard preview
|
|
||||||
- Recent activity
|
|
||||||
- A **"Start Game"** button (prominent CTA) — the button exists in the UI but does NOT wire up to the quiz yet; the quiz flow is not being programmed at this stage
|
|
||||||
- The exact set of dashboard widgets/tabs is TBD — design it to look complete and polished even if most data is placeholder
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quiz UI (To Be Built — Design Reference)
|
|
||||||
|
|
||||||
### General Flow
|
|
||||||
The quiz covers **two sections**: Math and English. The user completes them in order.
|
|
||||||
|
|
||||||
After finishing a section, the app checks answers and shows one of two outcomes:
|
|
||||||
- **100% correct** → proceed to the next section (or end the quiz if both are done)
|
|
||||||
- **At least 1 wrong** → must redo the section; keep retrying until 100% correct before advancing
|
|
||||||
|
|
||||||
There is no partial credit / moving on with mistakes — the player must get a perfect section to continue.
|
|
||||||
|
|
||||||
### Math Section
|
|
||||||
- Questions are **pre-generated** (not from Gemini) but with **randomised values each time** so the same question template produces different numbers on each attempt
|
|
||||||
- Questions should be creative and contextual — avoid trivial `a + b` style arithmetic; think word problems, patterns, multi-step reasoning appropriate to the grade level
|
|
||||||
- Multiple choice format (matching the original EQAO UI style — see design reference below)
|
|
||||||
|
|
||||||
### English Section
|
|
||||||
- **Gemini API** generates a reading passage appropriate for the grade
|
|
||||||
- Gemini also generates comprehension questions based on that passage
|
|
||||||
- Gemini checks free-response and paragraph writing answers (not just multiple choice)
|
|
||||||
- The section combines reading comprehension and a short writing component
|
|
||||||
|
|
||||||
### Quiz UI Design
|
|
||||||
- The actual in-quiz UI (question display, answer choices, progress) should stay **visually identical or very close to the original MathGame design** — same layout, card style, fonts, colour scheme
|
|
||||||
- Reference the existing `index.css` (`.math-game`, `.choice-btn`, `.grade-btn`, etc.) and replicate that look for the new quiz component
|
|
||||||
- Upload of reference screenshots is pending — when provided, match them closely
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What's Not Built Yet
|
|
||||||
- [ ] API routes (auth, sessions, leaderboard, questions)
|
|
||||||
- [ ] Gemini service for English passage + question generation and answer checking
|
|
||||||
- [BUILT] Math question templates with randomised values
|
|
||||||
- [BUILT] Quiz game component (replaces scrapped MathGame.jsx)
|
|
||||||
- [HALF-BUILT] Post-login Dashboard (rich version, not "Coming Soon" placeholder)
|
|
||||||
- [HALF-BUILT] Pre-login landing page extended layout + top nav (pre-release)
|
|
||||||
- [ ] Question seeding/storage strategy
|
|
||||||
- [ ] Frontend wiring for "Start Game" button
|
|
||||||
@@ -6,6 +6,8 @@ import Game from './pages/Game';
|
|||||||
import Queue from './pages/Queue';
|
import Queue from './pages/Queue';
|
||||||
import RankedGame from './pages/RankedGame';
|
import RankedGame from './pages/RankedGame';
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
|
import About from './pages/About';
|
||||||
|
import FAQ from './pages/FAQ';
|
||||||
|
|
||||||
function getValidStoredToken() {
|
function getValidStoredToken() {
|
||||||
const token = sessionStorage.getItem('ranked_token');
|
const token = sessionStorage.getItem('ranked_token');
|
||||||
@@ -41,6 +43,8 @@ export default function App() {
|
|||||||
<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="/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 />} />
|
||||||
|
<Route path="/about" element={<About />} />
|
||||||
|
<Route path="/faq" element={<FAQ />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
+44
-2
@@ -650,6 +650,19 @@ a { color: inherit; text-decoration: none; }
|
|||||||
background: var(--gold);
|
background: var(--gold);
|
||||||
color: #07080f;
|
color: #07080f;
|
||||||
box-shadow: 0 0 28px rgba(245,197,24,0.3), 0 4px 14px rgba(0,0,0,0.4);
|
box-shadow: 0 0 28px rgba(245,197,24,0.3), 0 4px 14px rgba(0,0,0,0.4);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-btn--gold::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(105deg, transparent 30%, rgba(255,255,255,0.35) 50%, transparent 70%);
|
||||||
|
background-size: 300% 100%;
|
||||||
|
background-position: -200% center;
|
||||||
|
animation: shimmerSweep 3s ease-in-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.db-btn--gold:hover {
|
.db-btn--gold:hover {
|
||||||
@@ -995,11 +1008,15 @@ a { color: inherit; text-decoration: none; }
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
transition: background 0.15s;
|
transition: background 0.15s, transform 0.15s, box-shadow 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.match-row:last-child { border-bottom: none; }
|
.match-row:last-child { border-bottom: none; }
|
||||||
.match-row:hover { background: rgba(255,255,255,0.02); }
|
.match-row:hover {
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.match-row--win { border-left: 2px solid #22c55e; }
|
.match-row--win { border-left: 2px solid #22c55e; }
|
||||||
.match-row--loss { border-left: 2px solid #ef4444; }
|
.match-row--loss { border-left: 2px solid #ef4444; }
|
||||||
@@ -2178,3 +2195,28 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.db-hero__name { font-size: clamp(38px, 13vw, 72px); }
|
.db-hero__name { font-size: clamp(38px, 13vw, 72px); }
|
||||||
.db-grades { grid-template-columns: 1fr; }
|
.db-grades { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Quiz nav button press feel */
|
||||||
|
.quiz-nav-btn {
|
||||||
|
transition: transform 0.1s ease !important;
|
||||||
|
}
|
||||||
|
.quiz-nav-btn:active {
|
||||||
|
transform: scale(0.96) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmerSweep {
|
||||||
|
0% { background-position: -200% center; }
|
||||||
|
100% { background-position: 200% center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rowFadeUp {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradeTabBounce {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
40% { transform: scale(0.92); }
|
||||||
|
70% { transform: scale(1.04); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
@@ -149,10 +149,11 @@ function LeaderboardTable({ entries, loading, highlightRank, tab }) {
|
|||||||
<span className="lb-header__player">Player</span>
|
<span className="lb-header__player">Player</span>
|
||||||
<span className="lb-header__time">{tab === 'elo' ? 'ELO' : 'Best Time'}</span>
|
<span className="lb-header__time">{tab === 'elo' ? 'ELO' : 'Best Time'}</span>
|
||||||
</div>
|
</div>
|
||||||
{padded.map(entry => {
|
{padded.map((entry, idx) => {
|
||||||
|
const rowAnim = { animation: 'rowFadeUp 0.3s ease-out both', animationDelay: `${idx * 40}ms` };
|
||||||
if (entry.ghost) {
|
if (entry.ghost) {
|
||||||
return (
|
return (
|
||||||
<div key={`g${entry.rank}`} className="lb-row lb-row--ghost">
|
<div key={`g${entry.rank}`} className="lb-row lb-row--ghost" style={rowAnim}>
|
||||||
<span className="lb-rank lb-rank--num">{entry.rank}</span>
|
<span className="lb-rank lb-rank--num">{entry.rank}</span>
|
||||||
<div className="lb-user">
|
<div className="lb-user">
|
||||||
<div className="lb-avatar lb-avatar--ghost" />
|
<div className="lb-avatar lb-avatar--ghost" />
|
||||||
@@ -167,7 +168,7 @@ function LeaderboardTable({ entries, loading, highlightRank, tab }) {
|
|||||||
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' : ''}`} style={rowAnim}>
|
||||||
<span className={`lb-rank${entry.rank > 3 ? ' lb-rank--num' : ''}`} style={rankStyle ?? undefined}>
|
<span className={`lb-rank${entry.rank > 3 ? ' lb-rank--num' : ''}`} style={rankStyle ?? undefined}>
|
||||||
{entry.rank}
|
{entry.rank}
|
||||||
</span>
|
</span>
|
||||||
@@ -193,41 +194,70 @@ function MatchHistory({ matches, loading }) {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '12px 16px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '12px 16px' }}>
|
||||||
{[0,1,2].map(i => <div key={i} className="skel-pill" style={{ height: 52, animationDelay: `${i*0.07}s` }} />)}
|
{[0, 1, 2].map(i => (
|
||||||
|
<div key={i} className="skel-pill" style={{ height: 60, animationDelay: `${i * 0.07}s` }} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!matches.length) {
|
if (!matches.length) {
|
||||||
return (
|
return (
|
||||||
<div className="dash-empty">
|
<div className="dash-empty">
|
||||||
<div className="dash-empty__icon">⚔️</div>
|
<div className="dash-empty__icon">🕐</div>
|
||||||
<span className="dash-empty__text">No ranked matches yet</span>
|
<span className="dash-empty__text">No games played yet</span>
|
||||||
<span className="dash-empty__sub">Queue up to start climbing</span>
|
<span className="dash-empty__sub">Queue up to start climbing</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="match-history">
|
<div className="match-history">
|
||||||
{matches.map((m, i) => {
|
{matches.slice(0, 5).map((m, i) => {
|
||||||
const sign = m.eloChange >= 0 ? '+' : '';
|
const sign = m.eloChange >= 0 ? '+' : '';
|
||||||
|
const eloDeltaColor = m.eloChange >= 0 ? '#1b8a2d' : '#c62828';
|
||||||
return (
|
return (
|
||||||
<div key={m.id ?? i} className={`match-row${m.won ? ' match-row--win' : ' match-row--loss'}`}>
|
<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'}`}>
|
{/* Win/loss emoji icon */}
|
||||||
{m.won ? 'W' : 'L'}
|
<div
|
||||||
|
className={`match-chip${m.won ? ' match-chip--win' : ' match-chip--loss'}`}
|
||||||
|
style={{ fontSize: 20, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
>
|
||||||
|
{m.won ? '🏆' : '💀'}
|
||||||
</div>
|
</div>
|
||||||
<div className="match-info">
|
|
||||||
<span className="match-elo-after">{m.eloAfter.toLocaleString()} ELO</span>
|
{/* Center: grade badge · label · ELO delta · time · ago */}
|
||||||
{m.timeSpentMs != null && (
|
<div className="match-info" style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
<span className="match-time">{fmtTime(m.timeSpentMs)}</span>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7, flexWrap: 'wrap' }}>
|
||||||
)}
|
<span style={{
|
||||||
</div>
|
fontSize: 10, fontWeight: 700, padding: '2px 6px', borderRadius: 4,
|
||||||
<div className="match-right">
|
background: '#f0f4ff', color: '#1a73e8',
|
||||||
<span className={`match-elo-delta${m.eloChange >= 0 ? ' match-elo-delta--pos' : ' match-elo-delta--neg'}`}>
|
letterSpacing: '0.04em', fontFamily: 'system-ui,sans-serif',
|
||||||
{sign}{m.eloChange}
|
}}>
|
||||||
|
{m.grade}
|
||||||
</span>
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 14, fontWeight: 700,
|
||||||
|
color: m.won ? '#1b8a2d' : '#c62828',
|
||||||
|
fontFamily: 'system-ui,sans-serif',
|
||||||
|
}}>
|
||||||
|
{m.won ? 'Victory' : 'Defeat'}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 13, fontWeight: 700, color: eloDeltaColor,
|
||||||
|
fontFamily: 'system-ui,sans-serif',
|
||||||
|
}}>
|
||||||
|
{sign}{m.eloChange} ELO
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<span className="match-ago">{fmtRelative(m.playedAt)}</span>
|
<span className="match-ago">{fmtRelative(m.playedAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right: ELO after */}
|
||||||
|
<span className="match-elo-after" style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{m.eloAfter?.toLocaleString()} ELO
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -273,6 +303,8 @@ export default function Dashboard() {
|
|||||||
const [matchLoading, setMatchLoading] = useState(false);
|
const [matchLoading, setMatchLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [statsReady, setStatsReady] = useState(false);
|
const [statsReady, setStatsReady] = useState(false);
|
||||||
|
const [bouncingGrade, setBouncingGrade] = useState(null);
|
||||||
|
const [onlineCount, setOnlineCount] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@@ -327,6 +359,18 @@ export default function Dashboard() {
|
|||||||
.finally(() => setMatchLoading(false));
|
.finally(() => setMatchLoading(false));
|
||||||
}, [token, selectedGrade]);
|
}, [token, selectedGrade]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchOnline = () => {
|
||||||
|
fetch(`${API}/online-count`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => setOnlineCount(d.online ?? 0))
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
fetchOnline();
|
||||||
|
const id = setInterval(fetchOnline, 30000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const signOut = () => { sessionStorage.removeItem('ranked_token'); navigate('/'); };
|
const signOut = () => { sessionStorage.removeItem('ranked_token'); navigate('/'); };
|
||||||
|
|
||||||
const currentEntry = stats.find(e => e.grade === selectedGrade);
|
const currentEntry = stats.find(e => e.grade === selectedGrade);
|
||||||
@@ -402,7 +446,12 @@ export default function Dashboard() {
|
|||||||
<button
|
<button
|
||||||
key={g.id}
|
key={g.id}
|
||||||
className={`dash-grade-tab${selectedGrade === g.id ? ' dash-grade-tab--active' : ''}`}
|
className={`dash-grade-tab${selectedGrade === g.id ? ' dash-grade-tab--active' : ''}`}
|
||||||
onClick={() => setSelectedGrade(g.id)}
|
style={bouncingGrade === g.id ? { animation: 'gradeTabBounce 0.2s ease-out' } : {}}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedGrade(g.id);
|
||||||
|
setBouncingGrade(g.id);
|
||||||
|
setTimeout(() => setBouncingGrade(null), 200);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{g.label}
|
{g.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -420,6 +469,25 @@ export default function Dashboard() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Online count ── */}
|
||||||
|
{onlineCount !== null && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 8 }}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 12, fontWeight: 600, color: 'rgba(255,255,255,0.5)',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
fontFamily: 'system-ui, sans-serif',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
width: 7, height: 7, borderRadius: '50%',
|
||||||
|
background: '#4caf50',
|
||||||
|
boxShadow: '0 0 6px rgba(76,175,80,0.8)',
|
||||||
|
display: 'inline-block',
|
||||||
|
}} />
|
||||||
|
{onlineCount} {onlineCount === 1 ? 'player' : 'players'} online
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── ELO + Tier progress ── */}
|
{/* ── ELO + Tier progress ── */}
|
||||||
<div className="db-elo-block">
|
<div className="db-elo-block">
|
||||||
<div className="db-elo-block__inner">
|
<div className="db-elo-block__inner">
|
||||||
|
|||||||
+151
-26
@@ -10,16 +10,48 @@ function fmtDuration(ms) {
|
|||||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function GlobalStyles() {
|
||||||
|
return (
|
||||||
|
<style>{`
|
||||||
|
@keyframes questionEnter { from{opacity:0;transform:translateX(20px)} to{opacity:1;transform:translateX(0)} }
|
||||||
|
@keyframes answerPulse { 0%{transform:scale(1)} 50%{transform:scale(0.97)} 100%{transform:scale(1)} }
|
||||||
|
@keyframes submitGlow { from{box-shadow:0 0 8px #ff69b4aa} to{box-shadow:0 0 22px #ff69b4,0 0 40px #ff69b466} }
|
||||||
|
@keyframes bubblePop { 0%{transform:scale(0.7)} 70%{transform:scale(1.1)} 100%{transform:scale(1)} }
|
||||||
|
@keyframes titleFadeDown { from{opacity:0;transform:translateY(-16px)} to{opacity:1;transform:translateY(0)} }
|
||||||
|
@keyframes statsFadeUp { from{opacity:0;transform:translateY(12px)} to{opacity:1;transform:translateY(0)} }
|
||||||
|
@keyframes spin { to{transform:rotate(360deg);} }
|
||||||
|
`}</style>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── ProgressStrip ──────────────────────────────────────────────
|
// ── ProgressStrip ──────────────────────────────────────────────
|
||||||
function ProgressStrip({ total, current, answers, onJump }) {
|
function ProgressStrip({ total, current, answers, onJump }) {
|
||||||
|
const [fillCounts, setFillCounts] = useState({});
|
||||||
|
const prevAnswersRef = useRef({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updates = {};
|
||||||
|
for (let qi = 0; qi < total; qi++) {
|
||||||
|
if (answers[qi] != null && prevAnswersRef.current[qi] == null) updates[qi] = true;
|
||||||
|
}
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
setFillCounts(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const qi of Object.keys(updates)) next[Number(qi)] = (prev[Number(qi)] || 0) + 1;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
prevAnswersRef.current = answers;
|
||||||
|
}, [answers, total]);
|
||||||
|
|
||||||
const items = [];
|
const items = [];
|
||||||
for (let qi = 0; qi < total; qi++) {
|
for (let qi = 0; qi < total; qi++) {
|
||||||
const active = qi === current;
|
const active = qi === current;
|
||||||
const answered = answers[qi] != null;
|
const filled = answers[qi] != null;
|
||||||
const filled = answered;
|
const popCount = fillCounts[qi] || 0;
|
||||||
items.push(
|
items.push(
|
||||||
<button
|
<button
|
||||||
key={`b-${qi}`}
|
key={`b-${qi}-${popCount}`}
|
||||||
onClick={() => onJump && onJump(qi)}
|
onClick={() => onJump && onJump(qi)}
|
||||||
style={{
|
style={{
|
||||||
width: 36, height: 36, borderRadius: '50%',
|
width: 36, height: 36, borderRadius: '50%',
|
||||||
@@ -30,6 +62,7 @@ function ProgressStrip({ total, current, answers, onJump }) {
|
|||||||
fontSize: 13, fontWeight: 700, fontFamily: 'system-ui,-apple-system,sans-serif',
|
fontSize: 13, fontWeight: 700, fontFamily: 'system-ui,-apple-system,sans-serif',
|
||||||
flexShrink: 0, cursor: 'pointer', padding: 0, boxSizing: 'border-box',
|
flexShrink: 0, cursor: 'pointer', padding: 0, boxSizing: 'border-box',
|
||||||
transition: 'background 0.15s, border-color 0.15s',
|
transition: 'background 0.15s, border-color 0.15s',
|
||||||
|
animation: popCount > 0 ? 'bubblePop 0.25s ease-out' : 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{qi + 1}
|
{qi + 1}
|
||||||
@@ -95,7 +128,10 @@ function StageIntro({ stage, onNext }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext, onBack, onJump, onQuit }) {
|
function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext, onBack, onJump, onQuit, grade }) {
|
||||||
|
const [pulsingIdx, setPulsingIdx] = useState(null);
|
||||||
|
const [hoverIdx, setHoverIdx] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e) => {
|
const handler = (e) => {
|
||||||
const i = +e.key - 1;
|
const i = +e.key - 1;
|
||||||
@@ -108,16 +144,24 @@ function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext
|
|||||||
}, [q.choices.length, qIdx, onSelect, onNext, onBack]);
|
}, [q.choices.length, qIdx, onSelect, onNext, onBack]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ background: '#cce8f4', minHeight: '100vh', fontFamily: 'system-ui,-apple-system,sans-serif' }}>
|
<div style={{ background: '#07080f', 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 className="arena-bg"><div className="arena-grain" /></div>
|
||||||
|
<div style={{ maxWidth: 760, margin: '0 auto', background: '#fff', minHeight: '100vh', display: 'flex', flexDirection: 'column', position: 'relative', zIndex: 1, borderTop: '3px solid #1a73e8', boxShadow: '0 0 60px rgba(26,115,232,0.08), 0 0 40px rgba(0,60,120,0.10)' }}>
|
||||||
<div style={{ borderBottom: '1px solid #e8e8e8', padding: '24px 32px 20px' }}>
|
<div style={{ borderBottom: '1px solid #e8e8e8', padding: '24px 32px 20px' }}>
|
||||||
<p style={{ fontSize: 36, fontWeight: 700, color: '#333', margin: '0 0 20px 0', lineHeight: 1 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, margin: '0 0 20px 0' }}>
|
||||||
|
<p style={{ fontSize: 36, fontWeight: 700, color: '#333', margin: 0, lineHeight: 1, fontFamily: "'Barlow Condensed', system-ui, sans-serif", letterSpacing: '0.01em' }}>
|
||||||
Question {qIdx + 1} of {STAGE_SIZE}
|
Question {qIdx + 1} of {STAGE_SIZE}
|
||||||
</p>
|
</p>
|
||||||
|
{grade && (
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, padding: '3px 8px', borderRadius: 4, background: 'rgba(26,115,232,0.08)', border: '1px solid rgba(26,115,232,0.25)', color: '#1a73e8', letterSpacing: '0.06em', fontFamily: 'system-ui,sans-serif', flexShrink: 0 }}>
|
||||||
|
{grade}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<ProgressStrip total={STAGE_SIZE} current={qIdx} answers={answers} onJump={onJump} />
|
<ProgressStrip total={STAGE_SIZE} current={qIdx} answers={answers} onJump={onJump} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1, padding: '28px 32px 24px' }}>
|
<div key={qIdx} style={{ flex: 1, padding: '28px 32px 24px', animation: 'questionEnter 0.2s ease-out' }}>
|
||||||
<p style={{ fontSize: 24, color: '#111', lineHeight: 1.7, whiteSpace: 'pre-line', fontWeight: 500, margin: '0 0 28px 0' }}>
|
<p style={{ fontSize: 24, color: '#111', lineHeight: 1.7, whiteSpace: 'pre-line', fontWeight: 500, margin: '0 0 28px 0' }}>
|
||||||
{q.question}
|
{q.question}
|
||||||
</p>
|
</p>
|
||||||
@@ -127,20 +171,28 @@ function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext
|
|||||||
}>
|
}>
|
||||||
{q.choices.map((choice, i) => {
|
{q.choices.map((choice, i) => {
|
||||||
const sel = selected === i;
|
const sel = selected === i;
|
||||||
|
const hov = hoverIdx === i && !sel;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => onSelect(i)}
|
onClick={() => {
|
||||||
|
onSelect(i);
|
||||||
|
setPulsingIdx(i);
|
||||||
|
setTimeout(() => setPulsingIdx(p => p === i ? null : p), 150);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHoverIdx(i)}
|
||||||
|
onMouseLeave={() => setHoverIdx(null)}
|
||||||
style={{
|
style={{
|
||||||
minHeight: 64, padding: '18px 24px',
|
minHeight: 64, padding: '18px 24px',
|
||||||
background: sel ? '#e8f0fe' : '#f7f8fa',
|
background: sel ? '#e8f0fe' : hov ? '#eef2ff' : '#f7f8fa',
|
||||||
border: `2px solid ${sel ? '#1a73e8' : '#e0e0e0'}`,
|
border: `2px solid ${sel ? '#1a73e8' : hov ? '#1a73e8' : '#e0e0e0'}`,
|
||||||
borderRadius: 12, fontSize: 20,
|
borderRadius: 12, fontSize: 20,
|
||||||
color: sel ? '#1a73e8' : '#222',
|
color: sel ? '#1a73e8' : '#222',
|
||||||
textAlign: 'left', cursor: 'pointer',
|
textAlign: 'left', cursor: 'pointer',
|
||||||
fontWeight: sel ? 600 : 400,
|
fontWeight: sel ? 600 : 400,
|
||||||
fontFamily: 'system-ui,-apple-system,sans-serif', lineHeight: 1.45,
|
fontFamily: 'system-ui,-apple-system,sans-serif', lineHeight: 1.45,
|
||||||
transition: 'background 0.12s, border-color 0.12s',
|
transition: 'background 0.12s, border-color 0.12s',
|
||||||
|
animation: pulsingIdx === i ? 'answerPulse 0.15s ease-out' : 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 14, fontWeight: 700, color: sel ? '#1a73e8' : '#aaa', marginRight: 20, opacity: 0.7 }}>
|
<span style={{ fontSize: 14, fontWeight: 700, color: sel ? '#1a73e8' : '#aaa', marginRight: 20, opacity: 0.7 }}>
|
||||||
@@ -156,13 +208,17 @@ function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext
|
|||||||
<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 }}>
|
<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>
|
<button style={QUIT_BTN} onClick={onQuit}>Quit</button>
|
||||||
<div style={{ display: 'flex', gap: 14 }}>
|
<div style={{ display: 'flex', gap: 14 }}>
|
||||||
{qIdx > 0 && <button style={NAV_BTN} onClick={onBack}>‹ Back</button>}
|
{qIdx > 0 && <button className="quiz-nav-btn" 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
|
||||||
|
className="quiz-nav-btn"
|
||||||
|
style={{ padding: '14px 40px', borderRadius: 999, fontSize: 17, fontWeight: 700, cursor: 'pointer', border: 'none', fontFamily: 'system-ui,sans-serif', background: '#ff69b4', color: '#000', animation: 'submitGlow 1.5s ease-in-out infinite alternate' }}
|
||||||
|
onClick={onNext}
|
||||||
|
>
|
||||||
Submit ★
|
Submit ★
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button style={NAV_BTN} onClick={onNext}>Next ›</button>
|
<button className="quiz-nav-btn" style={NAV_BTN} onClick={onNext}>Next ›</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -267,24 +323,67 @@ function StageResult({ stage, wrongCount, questions, answers: initialAnswers, on
|
|||||||
|
|
||||||
function CompleteScreen({ duration, isNewBest, onDashboard }) {
|
function CompleteScreen({ duration, isNewBest, onDashboard }) {
|
||||||
return (
|
return (
|
||||||
<div style={WRAP}>
|
<div style={{
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '40px 32px', textAlign: 'center', gap: 20 }}>
|
minHeight: '100vh',
|
||||||
<div style={{ fontSize: 60 }}>🎉</div>
|
background: 'radial-gradient(ellipse at 50% 0%, rgba(245,197,24,0.12) 0%, transparent 60%), #07080f',
|
||||||
<h1 style={{ fontSize: 30, fontWeight: 800, color: '#111' }}>Quiz Complete!</h1>
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||||
<p style={{ fontSize: 17, color: '#555' }}>You completed both stages of the Mathematics quiz.</p>
|
fontFamily: 'system-ui,sans-serif', position: 'relative', overflow: 'hidden',
|
||||||
<div style={{ padding: '14px 40px', background: '#f5f7ff', border: '2px solid #c5d3f8', borderRadius: 12, fontSize: 24, fontWeight: 700, color: '#1a73e8' }}>
|
}}>
|
||||||
⏱ {fmtDuration(duration)}
|
<div style={{ width: '100%', maxWidth: 480, padding: '0 24px', textAlign: 'center', boxSizing: 'border-box' }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 11, fontWeight: 700, letterSpacing: '0.3em', textTransform: 'uppercase',
|
||||||
|
color: 'rgba(245,197,24,0.45)', marginBottom: 16,
|
||||||
|
}}>
|
||||||
|
Mathematics
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 'clamp(64px, 10vw, 96px)', fontWeight: 900,
|
||||||
|
color: '#f5c518', letterSpacing: '0.05em', lineHeight: 1,
|
||||||
|
fontFamily: '"Russo One", system-ui, sans-serif',
|
||||||
|
animation: 'titleFadeDown 0.5s ease-out both',
|
||||||
|
}}>
|
||||||
|
COMPLETE
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 80, height: 2, background: '#f5c518', margin: '16px auto 24px' }} />
|
||||||
|
<div style={{
|
||||||
|
fontSize: 'clamp(36px, 6vw, 56px)', fontWeight: 700, color: '#fff',
|
||||||
|
fontFamily: 'monospace', letterSpacing: '0.08em',
|
||||||
|
animation: 'statsFadeUp 0.4s ease-out 0.3s both',
|
||||||
|
}}>
|
||||||
|
{fmtDuration(duration)}
|
||||||
</div>
|
</div>
|
||||||
{isNewBest && (
|
{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', boxShadow: '0 0 12px rgba(245,197,24,0.45)' }}>
|
<div style={{
|
||||||
🏆 New Personal Best!
|
display: 'inline-block', marginTop: 16,
|
||||||
|
padding: '8px 24px',
|
||||||
|
background: 'rgba(245,197,24,0.12)',
|
||||||
|
border: '1.5px solid rgba(245,197,24,0.5)',
|
||||||
|
borderRadius: 999, fontSize: 14, fontWeight: 700,
|
||||||
|
color: '#f5c518', letterSpacing: '0.06em', textTransform: 'uppercase',
|
||||||
|
boxShadow: '0 0 12px rgba(245,197,24,0.35)',
|
||||||
|
animation: 'statsFadeUp 0.4s ease-out 0.45s both',
|
||||||
|
}}>
|
||||||
|
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' }}>
|
<div style={{ marginTop: 32, animation: 'statsFadeUp 0.4s ease-out 0.55s both' }}>
|
||||||
|
<button
|
||||||
|
onClick={onDashboard}
|
||||||
|
style={{
|
||||||
|
padding: '14px 48px', borderRadius: 6, fontSize: 15, fontWeight: 700,
|
||||||
|
letterSpacing: '0.08em', textTransform: 'uppercase', cursor: 'pointer',
|
||||||
|
border: '1px solid rgba(245,197,24,0.5)',
|
||||||
|
background: 'rgba(245,197,24,0.1)', color: '#f5c518',
|
||||||
|
fontFamily: 'system-ui,sans-serif',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(245,197,24,0.2)'; e.currentTarget.style.borderColor = '#f5c518'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(245,197,24,0.1)'; e.currentTarget.style.borderColor = 'rgba(245,197,24,0.5)'; }}
|
||||||
|
>
|
||||||
Back to Dashboard
|
Back to Dashboard
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +394,11 @@ export default function Game() {
|
|||||||
const { state } = useLocation();
|
const { state } = useLocation();
|
||||||
const grade = state?.grade ?? null;
|
const grade = state?.grade ?? null;
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
navigate('/dashboard', { replace: true });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const [phase, setPhase] = useState('loading');
|
const [phase, setPhase] = useState('loading');
|
||||||
const [stage, setStage] = useState(1);
|
const [stage, setStage] = useState(1);
|
||||||
const [questions, setQuestions] = useState([]);
|
const [questions, setQuestions] = useState([]);
|
||||||
@@ -306,6 +410,7 @@ export default function Game() {
|
|||||||
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 [isNewBest, setIsNewBest] = useState(false);
|
||||||
|
const [stageFlash, setStageFlash] = useState(false);
|
||||||
|
|
||||||
const timerStartRef = useRef(null);
|
const timerStartRef = useRef(null);
|
||||||
const intervalRef = useRef(null);
|
const intervalRef = useRef(null);
|
||||||
@@ -422,12 +527,17 @@ export default function Game() {
|
|||||||
async function handleResultNext() {
|
async function handleResultNext() {
|
||||||
if (stage === 1) {
|
if (stage === 1) {
|
||||||
s1CorrectRef.current = STAGE_SIZE - wrongCount;
|
s1CorrectRef.current = STAGE_SIZE - wrongCount;
|
||||||
|
setStageFlash(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setStageFlash(false);
|
||||||
loadStage(2);
|
loadStage(2);
|
||||||
|
}, 800);
|
||||||
} else {
|
} else {
|
||||||
endSession(s1CorrectRef.current + (STAGE_SIZE - wrongCount));
|
endSession(s1CorrectRef.current + (STAGE_SIZE - wrongCount));
|
||||||
const token = sessionStorage.getItem('ranked_token');
|
const token = sessionStorage.getItem('ranked_token');
|
||||||
const timeMs = timerStartRef.current ? Date.now() - timerStartRef.current : 0;
|
const timeMs = timerStartRef.current ? Date.now() - timerStartRef.current : 0;
|
||||||
stopTimer();
|
stopTimer();
|
||||||
|
console.log('Submitting best time:', grade, timeMs);
|
||||||
let isNewBest = false;
|
let isNewBest = false;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/me/best-time`, {
|
const res = await fetch(`${API}/me/best-time`, {
|
||||||
@@ -454,11 +564,10 @@ export default function Game() {
|
|||||||
if (phase === 'loading') return (
|
if (phase === 'loading') return (
|
||||||
<div style={{ ...WRAP, alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ ...WRAP, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<div style={{ width: 40, height: 40, borderRadius: '50%', border: '3px solid #e0e0e0', borderTopColor: '#1a73e8', animation: 'spin 0.8s linear infinite' }} />
|
<div style={{ width: 40, height: 40, borderRadius: '50%', border: '3px solid #e0e0e0', borderTopColor: '#1a73e8', animation: 'spin 0.8s linear infinite' }} />
|
||||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
|
||||||
</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} onQuit={handleQuit} />;
|
if (phase === 'question') return <QuestionScreen question={questions[qIdx]} qIdx={qIdx} answers={answers} selected={selected} onSelect={setSelected} onNext={handleNext} onBack={handleBack} onJump={handleJump} onQuit={handleQuit} grade={grade} />;
|
||||||
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} isNewBest={isNewBest} onDashboard={() => navigate('/dashboard')} />;
|
if (phase === 'complete') return <CompleteScreen duration={duration} isNewBest={isNewBest} onDashboard={() => navigate('/dashboard')} />;
|
||||||
return null;
|
return null;
|
||||||
@@ -466,6 +575,7 @@ export default function Game() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<GlobalStyles />
|
||||||
{timerRunning && (
|
{timerRunning && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', top: 18, right: 24, zIndex: 200,
|
position: 'fixed', top: 18, right: 24, zIndex: 200,
|
||||||
@@ -480,6 +590,21 @@ export default function Game() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{renderPhase()}
|
{renderPhase()}
|
||||||
|
{stageFlash && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 9999,
|
||||||
|
background: '#07080f',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 'clamp(28px, 5vw, 48px)', fontWeight: 900, color: '#f5c518',
|
||||||
|
fontFamily: '"Russo One", system-ui, sans-serif',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
}}>
|
||||||
|
Stage 1 Complete.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+728
-102
@@ -1,131 +1,418 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import LeaderboardModal from '../components/LeaderboardModal';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
const API = import.meta.env.VITE_API_URL;
|
const API = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
/* Animated background shapes — generated once, stable positions */
|
|
||||||
const DIAMONDS = [
|
const DIAMONDS = [
|
||||||
{ left: '8%', top: '15%', size: 14, dur: '9s', delay: '0s' },
|
{ left: '5%', top: '12%', size: 10, dur: '9s', delay: '0s' },
|
||||||
{ left: '22%', top: '60%', size: 10, dur: '13s', delay: '2s' },
|
{ left: '18%', top: '58%', size: 8, dur: '13s', delay: '2s' },
|
||||||
{ left: '55%', top: '25%', size: 18, dur: '11s', delay: '0.5s' },
|
{ left: '30%', top: '22%', size: 14, dur: '11s', delay: '0.5s' },
|
||||||
{ left: '72%', top: '70%', size: 12, dur: '8s', delay: '3s' },
|
{ left: '48%', top: '72%', size: 7, dur: '10s', delay: '1.5s' },
|
||||||
{ left: '88%', top: '35%', size: 16, dur: '14s', delay: '1s' },
|
{ left: '58%', top: '20%', size: 16, dur: '12s', delay: '0.8s' },
|
||||||
{ left: '38%', top: '80%', size: 8, dur: '10s', delay: '4s' },
|
{ left: '70%', top: '65%', size: 10, dur: '8s', delay: '3s' },
|
||||||
{ left: '64%', top: '12%', size: 11, dur: '12s', delay: '1.5s' },
|
{ left: '82%', top: '30%', size: 13, dur: '14s', delay: '1s' },
|
||||||
|
{ left: '90%', top: '75%', size: 9, dur: '9s', delay: '4s' },
|
||||||
|
{ left: '38%', top: '45%', size: 6, dur: '15s', delay: '2.5s' },
|
||||||
|
{ left: '62%', top: '88%', size: 11, dur: '11s', delay: '0.3s' },
|
||||||
|
{ left: '12%', top: '82%', size: 8, dur: '13s', delay: '1.8s' },
|
||||||
|
{ left: '76%', top: '10%', size: 12, dur: '10s', delay: '3.5s' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const RINGS = [
|
const RINGS = [
|
||||||
{ left: '15%', top: '40%', size: 60, dur: '7s', delay: '0s' },
|
{ left: '10%', top: '35%', size: 60, dur: '7s', delay: '0s' },
|
||||||
{ left: '80%', top: '20%', size: 44, dur: '9s', delay: '2.5s' },
|
{ left: '78%', top: '18%', size: 44, dur: '9s', delay: '2.5s' },
|
||||||
{ left: '50%', top: '65%', size: 80, dur: '11s', delay: '1s' },
|
{ left: '45%', top: '62%', size: 80, dur: '11s', delay: '1s' },
|
||||||
|
{ left: '88%', top: '55%', size: 36, dur: '8s', delay: '3.5s' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SCANLINES = [
|
const SCANLINES = [
|
||||||
{ top: '30%', width: '160px', left: '5%', dur: '8s', delay: '0s' },
|
{ top: '28%', width: '160px', left: '5%', dur: '8s', delay: '0s' },
|
||||||
{ top: '55%', width: '120px', left: '75%', dur: '10s', delay: '3s' },
|
{ top: '52%', width: '120px', left: '74%', dur: '10s', delay: '3s' },
|
||||||
{ top: '20%', width: '200px', left: '40%', dur: '12s', delay: '1.5s' },
|
{ top: '18%', width: '200px', left: '38%', dur: '12s', delay: '1.5s' },
|
||||||
|
{ top: '75%', width: '140px', left: '20%', dur: '9s', delay: '2s' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const INPUT_STYLE = {
|
||||||
|
width: '100%', padding: '12px 16px', background: 'rgba(255,255,255,0.07)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.18)', borderRadius: 10, color: '#fff',
|
||||||
|
fontSize: 15, fontFamily: 'system-ui,sans-serif', outline: 'none', boxSizing: 'border-box',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ERROR_MSGS = {
|
||||||
|
email_taken: 'An account with this email already exists.',
|
||||||
|
username_taken: 'That username is already taken.',
|
||||||
|
use_google: 'This account uses Google sign-in — use the Google button above.',
|
||||||
|
invalid_credentials: 'Incorrect email or password.',
|
||||||
|
invalid_email: 'Please enter a valid email address.',
|
||||||
|
password_too_short: 'Password must be at least 8 characters.',
|
||||||
|
invalid_username: 'Username must be 3–20 characters: letters, numbers, underscores only.',
|
||||||
|
too_many_attempts: 'Too many attempts — please wait a few minutes and try again.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RANK_COLORS = ['#f5c518', '#c0ccda', '#e0915a'];
|
||||||
|
|
||||||
|
function fmtTime(ms) {
|
||||||
|
if (ms == null) return '—';
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LANDING_CSS = `
|
||||||
|
@keyframes heroPulse {
|
||||||
|
0%,100% { opacity:.2; transform:translate(-50%,-50%) scale(1); }
|
||||||
|
50% { opacity:.38; transform:translate(-50%,-50%) scale(1.12); }
|
||||||
|
}
|
||||||
|
@keyframes wordReveal {
|
||||||
|
from { opacity:0; transform:translateY(28px); }
|
||||||
|
to { opacity:1; transform:translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from { opacity:0; transform:translateY(16px); }
|
||||||
|
to { opacity:1; transform:translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes scrollBounce {
|
||||||
|
0%,100% { transform:translateY(0); }
|
||||||
|
50% { transform:translateY(5px); }
|
||||||
|
}
|
||||||
|
.lp-hero-orb {
|
||||||
|
position:absolute; top:50%; left:50%;
|
||||||
|
width:1100px; height:700px;
|
||||||
|
background:radial-gradient(ellipse at center,
|
||||||
|
rgba(245,197,24,.07) 0%,
|
||||||
|
rgba(37,99,235,.05) 45%,
|
||||||
|
transparent 70%);
|
||||||
|
border-radius:50%;
|
||||||
|
pointer-events:none;
|
||||||
|
animation:heroPulse 10s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.lp-word { display:block; animation:wordReveal .7s cubic-bezier(.16,1,.3,1) both; }
|
||||||
|
.lp-word-1 { animation-delay:.15s; }
|
||||||
|
.lp-word-2 { animation-delay:.3s; }
|
||||||
|
.lp-word-3 { animation-delay:.45s; }
|
||||||
|
.lp-nav {
|
||||||
|
position:fixed; top:0; left:0; right:0; z-index:100;
|
||||||
|
height:64px; display:flex; align-items:center;
|
||||||
|
justify-content:space-between; padding:0 40px;
|
||||||
|
background:rgba(7,8,15,.85);
|
||||||
|
backdrop-filter:blur(20px); -webkit-backdrop-filter:blur(20px);
|
||||||
|
border-bottom:1px solid rgba(255,255,255,.07);
|
||||||
|
}
|
||||||
|
.lp-nav__logo {
|
||||||
|
font-family:'Russo One',sans-serif; font-size:19px;
|
||||||
|
letter-spacing:.04em; text-transform:uppercase;
|
||||||
|
color:#eceef8; background:none; border:none; cursor:pointer; padding:0;
|
||||||
|
}
|
||||||
|
.lp-nav__logo span { color:#f5c518; }
|
||||||
|
.lp-nav__links { display:flex; align-items:center; gap:28px; }
|
||||||
|
.lp-nav-link {
|
||||||
|
font-family:'Barlow Condensed',sans-serif; font-size:13px;
|
||||||
|
font-weight:600; letter-spacing:.1em; text-transform:uppercase;
|
||||||
|
color:#7a85a8; text-decoration:none; cursor:pointer;
|
||||||
|
background:none; border:none; padding:0; transition:color .2s;
|
||||||
|
}
|
||||||
|
.lp-nav-link:hover { color:#eceef8; }
|
||||||
|
.lp-nav-signin {
|
||||||
|
padding:8px 18px;
|
||||||
|
background:rgba(245,197,24,.08); border:1px solid rgba(245,197,24,.35);
|
||||||
|
border-radius:6px; font-family:'Barlow Condensed',sans-serif;
|
||||||
|
font-size:13px; font-weight:700; letter-spacing:.1em; text-transform:uppercase;
|
||||||
|
color:#f5c518; cursor:pointer; transition:background .2s, border-color .2s;
|
||||||
|
}
|
||||||
|
.lp-nav-signin:hover { background:rgba(245,197,24,.16); border-color:rgba(245,197,24,.6); }
|
||||||
|
.lp-container { width:100%; max-width:1100px; margin:0 auto; padding:0 24px; }
|
||||||
|
.lp-eyebrow {
|
||||||
|
font-family:'Barlow Condensed',sans-serif; font-size:11px;
|
||||||
|
font-weight:700; letter-spacing:.3em; text-transform:uppercase;
|
||||||
|
color:#f5c518; display:flex; align-items:center; justify-content:center;
|
||||||
|
gap:12px; margin-bottom:14px;
|
||||||
|
}
|
||||||
|
.lp-eyebrow::before,.lp-eyebrow::after {
|
||||||
|
content:''; display:block; width:36px; height:1px;
|
||||||
|
background:rgba(245,197,24,.4);
|
||||||
|
}
|
||||||
|
.lp-section-title {
|
||||||
|
font-family:'Russo One',sans-serif;
|
||||||
|
font-size:clamp(30px,5vw,48px); text-transform:uppercase;
|
||||||
|
letter-spacing:.02em; color:#eceef8;
|
||||||
|
text-align:center; margin-bottom:56px;
|
||||||
|
}
|
||||||
|
.lp-step-card {
|
||||||
|
background:#111426; border:1px solid rgba(255,255,255,.07);
|
||||||
|
border-radius:16px; padding:36px 28px;
|
||||||
|
position:relative; overflow:hidden; flex:1; min-width:260px;
|
||||||
|
transition:border-color .25s, transform .25s;
|
||||||
|
}
|
||||||
|
.lp-step-card::before {
|
||||||
|
content:''; position:absolute; top:0; left:0; right:0; height:2px;
|
||||||
|
background:linear-gradient(to right,transparent,rgba(245,197,24,.65),transparent);
|
||||||
|
}
|
||||||
|
.lp-step-card:hover { border-color:rgba(245,197,24,.22); transform:translateY(-4px); }
|
||||||
|
.lp-feature-card {
|
||||||
|
background:#111426; border:1px solid rgba(255,255,255,.07);
|
||||||
|
border-radius:12px; padding:28px 24px;
|
||||||
|
transition:border-color .25s, transform .25s;
|
||||||
|
}
|
||||||
|
.lp-feature-card:hover { border-color:rgba(245,197,24,.2); transform:translateY(-3px); }
|
||||||
|
.lp-lb-card {
|
||||||
|
background:#111426; border:1px solid rgba(255,255,255,.07);
|
||||||
|
border-radius:16px; overflow:hidden; max-width:580px; margin:0 auto;
|
||||||
|
}
|
||||||
|
.lp-lb-row {
|
||||||
|
display:flex; align-items:center; gap:14px; padding:13px 20px;
|
||||||
|
border-bottom:1px solid rgba(255,255,255,.06); transition:background .15s;
|
||||||
|
}
|
||||||
|
.lp-lb-row:last-child { border-bottom:none; }
|
||||||
|
.lp-lb-row:hover { background:rgba(255,255,255,.02); }
|
||||||
|
.lp-signin-card {
|
||||||
|
background:rgba(17,20,38,.96); border:1px solid rgba(255,255,255,.1);
|
||||||
|
border-radius:20px; padding:40px 36px; max-width:440px; margin:0 auto;
|
||||||
|
backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px);
|
||||||
|
}
|
||||||
|
@media (max-width:768px) {
|
||||||
|
.lp-nav { padding:0 20px; }
|
||||||
|
.lp-nav-link.hide-mobile { display:none; }
|
||||||
|
.lp-signin-card { padding:28px 20px; }
|
||||||
|
.lp-step-card { min-width:100%; }
|
||||||
|
}
|
||||||
|
@media (max-width:500px) {
|
||||||
|
.lp-nav__links { gap:12px; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [lbOpen, setLbOpen] = useState(false);
|
const navigate = useNavigate();
|
||||||
const authError = new URLSearchParams(window.location.search).get('error') === 'auth_failed';
|
const authError = new URLSearchParams(window.location.search).get('error') === 'auth_failed';
|
||||||
|
|
||||||
|
const [authMode, setAuthMode] = useState('login');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [formError, setFormError] = useState('');
|
||||||
|
|
||||||
|
const [lbGrade, setLbGrade] = useState('G3');
|
||||||
|
const [lbEntries, setLbEntries] = useState([]);
|
||||||
|
const [lbLoading, setLbLoading] = useState(true);
|
||||||
|
|
||||||
|
function resetForm() { setEmail(''); setPassword(''); setUsername(''); setFormError(''); }
|
||||||
|
|
||||||
|
async function handleEmailAuth(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setFormError('');
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const isRegister = authMode === 'register';
|
||||||
|
const body = isRegister
|
||||||
|
? { email, password, ...(username.trim() ? { username: username.trim() } : {}) }
|
||||||
|
: { email, password };
|
||||||
|
const res = await fetch(`${API}/auth/${isRegister ? 'register' : 'login'}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) { setFormError(ERROR_MSGS[data.error] ?? 'Something went wrong. Please try again.'); return; }
|
||||||
|
sessionStorage.setItem('ranked_token', data.token);
|
||||||
|
navigate('/dashboard', { replace: true });
|
||||||
|
} catch {
|
||||||
|
setFormError('Something went wrong. Check your connection.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLbLoading(true);
|
||||||
|
fetch(`${API}/leaderboard?grade=${lbGrade}&type=time`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => setLbEntries(Array.isArray(d) ? d.slice(0, 5) : []))
|
||||||
|
.catch(() => setLbEntries([]))
|
||||||
|
.finally(() => setLbLoading(false));
|
||||||
|
}, [lbGrade]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authError) setTimeout(() => scrollTo('signin'), 400);
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
function scrollTo(id) {
|
||||||
|
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<LeaderboardModal isOpen={lbOpen} onClose={() => setLbOpen(false)} />
|
<style>{LANDING_CSS}</style>
|
||||||
|
|
||||||
{/* Animated background */}
|
{/* Fixed animated background */}
|
||||||
<div className="arena-bg">
|
<div className="arena-bg">
|
||||||
<div className="arena-grain" />
|
<div className="arena-grain" />
|
||||||
<div className="arena-shapes">
|
<div className="arena-shapes">
|
||||||
{DIAMONDS.map((d, i) => (
|
{DIAMONDS.map((d, i) => (
|
||||||
<div
|
<div key={i} className="shape shape--diamond" style={{ left: d.left, top: d.top, width: d.size, height: d.size, animationDuration: d.dur, animationDelay: d.delay }} />
|
||||||
key={i}
|
|
||||||
className="shape shape--diamond"
|
|
||||||
style={{
|
|
||||||
left: d.left,
|
|
||||||
top: d.top,
|
|
||||||
width: d.size,
|
|
||||||
height: d.size,
|
|
||||||
animationDuration: d.dur,
|
|
||||||
animationDelay: d.delay,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
{RINGS.map((r, i) => (
|
{RINGS.map((r, i) => (
|
||||||
<div
|
<div key={i} className="shape shape--ring" style={{ left: r.left, top: r.top, width: r.size, height: r.size, animationDuration: r.dur, animationDelay: r.delay }} />
|
||||||
key={i}
|
|
||||||
className="shape shape--ring"
|
|
||||||
style={{
|
|
||||||
left: r.left,
|
|
||||||
top: r.top,
|
|
||||||
width: r.size,
|
|
||||||
height: r.size,
|
|
||||||
animationDuration: r.dur,
|
|
||||||
animationDelay: r.delay,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
{SCANLINES.map((s, i) => (
|
{SCANLINES.map((s, i) => (
|
||||||
<div
|
<div key={i} className="shape shape--line" style={{ top: s.top, left: s.left, width: s.width, animationDuration: s.dur, animationDelay: s.delay }} />
|
||||||
key={i}
|
|
||||||
className="shape shape--line"
|
|
||||||
style={{
|
|
||||||
top: s.top,
|
|
||||||
left: s.left,
|
|
||||||
width: s.width,
|
|
||||||
animationDuration: s.dur,
|
|
||||||
animationDelay: s.delay,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page content */}
|
{/* ── Sticky Nav ─────────────────────────────────────── */}
|
||||||
<main className="page login">
|
<nav className="lp-nav">
|
||||||
<p className="login__eyebrow">Ontario Standardized Tests</p>
|
<button className="lp-nav__logo" onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}>
|
||||||
|
RANKED<span>EQAO</span>
|
||||||
|
</button>
|
||||||
|
<div className="lp-nav__links">
|
||||||
|
<a href="#how-it-works" className="lp-nav-link hide-mobile">How It Works</a>
|
||||||
|
<a href="#leaderboard" className="lp-nav-link hide-mobile">Leaderboard</a>
|
||||||
|
<a href="/about" className="lp-nav-link hide-mobile">About</a>
|
||||||
|
<a href="/faq" className="lp-nav-link hide-mobile">FAQ</a>
|
||||||
|
<button className="lp-nav-signin" onClick={() => scrollTo('signin')}>Sign In</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div className="login__hero">
|
{/* ── Page ───────────────────────────────────────────── */}
|
||||||
<h1 className="login__title">
|
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||||
<span>Ranked</span>
|
|
||||||
EQAO
|
{/* ── HERO ─────────────────────────────────────────── */}
|
||||||
</h1>
|
<section style={{
|
||||||
<p className="login__tagline">
|
minHeight: '100vh', display: 'flex', flexDirection: 'column',
|
||||||
Compete. Practice. <em>Dominate</em> your grade.
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: '100px 0 80px', textAlign: 'center',
|
||||||
|
position: 'relative', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div className="lp-hero-orb" />
|
||||||
|
|
||||||
|
<div className="lp-container" style={{ position: 'relative', zIndex: 1 }}>
|
||||||
|
<p style={{
|
||||||
|
fontFamily: "'Barlow Condensed',sans-serif", fontSize: 11, fontWeight: 700,
|
||||||
|
letterSpacing: '0.3em', textTransform: 'uppercase', color: '#f5c518',
|
||||||
|
marginBottom: 28, opacity: 0, animation: 'fadeUp .6s .05s ease both',
|
||||||
|
}}>
|
||||||
|
Ontario Standardized Tests
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h1 style={{
|
||||||
|
fontFamily: "'Russo One',sans-serif",
|
||||||
|
fontSize: 'clamp(68px,14vw,148px)',
|
||||||
|
lineHeight: 0.9, letterSpacing: '-0.01em',
|
||||||
|
textTransform: 'uppercase', marginBottom: 36,
|
||||||
|
}}>
|
||||||
|
<span className="lp-word lp-word-1" style={{ color: '#eceef8' }}>COMPETE.</span>
|
||||||
|
<span className="lp-word lp-word-2" style={{ color: '#eceef8' }}>PRACTICE.</span>
|
||||||
|
<span className="lp-word lp-word-3" style={{ color: '#f5c518', textShadow: '0 0 60px rgba(245,197,24,.4)' }}>DOMINATE.</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p style={{
|
||||||
|
fontFamily: "'Barlow',sans-serif", fontSize: 'clamp(15px,2.4vw,19px)',
|
||||||
|
color: '#9ba5c4', maxWidth: 560, margin: '0 auto 44px', lineHeight: 1.65,
|
||||||
|
opacity: 0, animation: 'fadeUp .6s .6s ease both',
|
||||||
|
}}>
|
||||||
|
The ranked EQAO quiz platform for Ontario students. Practice Grade 3, 6, and 9 math — then go head-to-head against real opponents.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', gap: 14, justifyContent: 'center', flexWrap: 'wrap',
|
||||||
|
marginBottom: 56, opacity: 0, animation: 'fadeUp .6s .75s ease both',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={() => scrollTo('signin')}
|
||||||
|
style={{
|
||||||
|
padding: '15px 38px', background: '#f5c518', color: '#07080f',
|
||||||
|
border: 'none', borderRadius: 10,
|
||||||
|
fontFamily: "'Russo One',sans-serif", fontSize: 17,
|
||||||
|
letterSpacing: '0.04em', textTransform: 'uppercase', cursor: 'pointer',
|
||||||
|
boxShadow: '0 0 32px rgba(245,197,24,.35), 0 4px 16px rgba(0,0,0,.4)',
|
||||||
|
transition: 'transform .15s, box-shadow .2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = '0 0 52px rgba(245,197,24,.5), 0 8px 24px rgba(0,0,0,.5)'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = '0 0 32px rgba(245,197,24,.35), 0 4px 16px rgba(0,0,0,.4)'; }}
|
||||||
|
>
|
||||||
|
Start Playing
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="#how-it-works"
|
||||||
|
style={{
|
||||||
|
padding: '15px 38px', background: 'transparent', color: '#eceef8',
|
||||||
|
border: '1.5px solid rgba(255,255,255,.22)', borderRadius: 10,
|
||||||
|
fontFamily: "'Russo One',sans-serif", fontSize: 17,
|
||||||
|
letterSpacing: '0.04em', textTransform: 'uppercase',
|
||||||
|
textDecoration: 'none', display: 'inline-flex', alignItems: 'center',
|
||||||
|
transition: 'border-color .2s, color .2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.borderColor = 'rgba(255,255,255,.5)'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'rgba(255,255,255,.22)'; }}
|
||||||
|
>
|
||||||
|
How It Works
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grade-badges">
|
{/* Grade badges */}
|
||||||
{[
|
<div style={{
|
||||||
{ id: 'G3', label: 'Grade 3' },
|
display: 'flex', gap: 12, justifyContent: 'center', flexWrap: 'wrap',
|
||||||
{ id: 'G6', label: 'Grade 6' },
|
opacity: 0, animation: 'fadeUp .6s .9s ease both',
|
||||||
{ id: 'G9', label: 'Grade 9' },
|
}}>
|
||||||
].map(({ id, label }) => (
|
{[{ id: 'G3', label: 'Grade 3' }, { id: 'G6', label: 'Grade 6' }, { id: 'G9', label: 'Grade 9' }].map(({ id, label }) => (
|
||||||
<div key={id} className="grade-badge">
|
<div key={id} className="grade-badge">
|
||||||
<span className="grade-badge__rank">{id}</span>
|
<span className="grade-badge__rank">{id}</span>
|
||||||
<span className="grade-badge__label">{label}</span>
|
<span className="grade-badge__label">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="login__cta">
|
|
||||||
{authError && (
|
|
||||||
<p className="login__error">Sign-in failed — please try again.</p>
|
|
||||||
)}
|
|
||||||
<a href={`${API}/auth/google`} className="btn-google">
|
|
||||||
<GoogleIcon />
|
|
||||||
Sign in with Google
|
|
||||||
</a>
|
|
||||||
<p className="login__fine">Free · No credit card required</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="login__footer">
|
{/* Scroll hint */}
|
||||||
Ranked EQAO · Built for Ontario Students
|
<div style={{
|
||||||
</footer>
|
position: 'absolute', bottom: 32, left: '50%', transform: 'translateX(-50%)',
|
||||||
</main>
|
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6,
|
||||||
|
opacity: 0, animation: 'fadeUp .6s 1.2s ease both',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontFamily: "'Barlow Condensed',sans-serif", fontSize: 10, letterSpacing: '0.2em', textTransform: 'uppercase', color: '#7a85a8' }}>Scroll</span>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="rgba(122,133,168,.55)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ animation: 'scrollBounce 2s ease-in-out infinite' }}>
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<button className="lb-trophy-btn" onClick={() => setLbOpen(true)} aria-label="Leaderboard">
|
{/* ── HOW IT WORKS ─────────────────────────────────── */}
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
<section id="how-it-works" style={{
|
||||||
|
padding: '100px 0',
|
||||||
|
background: 'rgba(13,15,28,.78)',
|
||||||
|
backdropFilter: 'blur(2px)', WebkitBackdropFilter: 'blur(2px)',
|
||||||
|
}}>
|
||||||
|
<div className="lp-container">
|
||||||
|
<p className="lp-eyebrow">Get Started</p>
|
||||||
|
<h2 className="lp-section-title">How It Works</h2>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}>
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
num: '01',
|
||||||
|
icon: (
|
||||||
|
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
title: 'Sign In',
|
||||||
|
desc: "Create a free account with Google or email. Pick your grade — G3, G6, or G9 — and you're ready to start.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: '02',
|
||||||
|
icon: (
|
||||||
|
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M12 20h9" />
|
||||||
|
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
title: 'Practice',
|
||||||
|
desc: 'Answer 22 EQAO-style math questions across two stages. Every question must be correct to advance — no partial credit, no shortcuts.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: '03',
|
||||||
|
icon: (
|
||||||
|
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" />
|
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" />
|
||||||
<path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" />
|
<path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" />
|
||||||
<path d="M4 22h16" />
|
<path d="M4 22h16" />
|
||||||
@@ -133,7 +420,358 @@ export default function Login() {
|
|||||||
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22" />
|
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22" />
|
||||||
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" />
|
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
),
|
||||||
|
title: 'Compete',
|
||||||
|
desc: 'Join the ranked queue and race against a real opponent. Your ELO rises or falls on the result. Climb from Bronze all the way to Diamond.',
|
||||||
|
},
|
||||||
|
].map((step, i) => (
|
||||||
|
<div key={i} className="lp-step-card">
|
||||||
|
<span style={{
|
||||||
|
fontFamily: "'Russo One',sans-serif", fontSize: 64, lineHeight: 1,
|
||||||
|
color: 'rgba(245,197,24,.1)', position: 'absolute', top: 16, right: 22,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}>
|
||||||
|
{step.num}
|
||||||
|
</span>
|
||||||
|
<div style={{
|
||||||
|
width: 52, height: 52,
|
||||||
|
background: 'rgba(245,197,24,.07)', border: '1px solid rgba(245,197,24,.2)',
|
||||||
|
borderRadius: 12, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
marginBottom: 20,
|
||||||
|
}}>
|
||||||
|
{step.icon}
|
||||||
|
</div>
|
||||||
|
<h3 style={{
|
||||||
|
fontFamily: "'Barlow Condensed',sans-serif", fontSize: 22, fontWeight: 700,
|
||||||
|
letterSpacing: '0.06em', textTransform: 'uppercase', color: '#eceef8', marginBottom: 10,
|
||||||
|
}}>
|
||||||
|
{step.title}
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontFamily: "'Barlow',sans-serif", fontSize: 15, color: '#7a85a8', lineHeight: 1.65 }}>
|
||||||
|
{step.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── FEATURES ─────────────────────────────────────── */}
|
||||||
|
<section id="features" style={{ padding: '100px 0' }}>
|
||||||
|
<div className="lp-container">
|
||||||
|
<p className="lp-eyebrow">The Platform</p>
|
||||||
|
<h2 className="lp-section-title">Why Ranked EQAO?</h2>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(290px,1fr))', gap: 16 }}>
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
icon: <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="5" y="2" width="14" height="20" rx="2"/><path d="M9 7h6M9 11h6M9 15h4"/></svg>,
|
||||||
|
title: 'Real EQAO-Style Questions',
|
||||||
|
desc: 'Randomized values on every attempt — same problem structure, different numbers. You can\'t just memorize answers.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>,
|
||||||
|
title: 'Live 1v1 Matchmaking',
|
||||||
|
desc: 'Race against real students in real time. First to correctly complete all questions wins the match and earns ELO.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>,
|
||||||
|
title: 'ELO Ranking System',
|
||||||
|
desc: 'Climb from Bronze through Silver, Gold, Platinum, and Diamond. Your rating reflects actual performance against real opponents.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>,
|
||||||
|
title: 'Best Time Leaderboard',
|
||||||
|
desc: 'Every second counts. Your fastest completion time is tracked separately on the global leaderboard.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>,
|
||||||
|
title: 'Grade-Specific Content',
|
||||||
|
desc: 'Separate queues, leaderboards, and ELO ratings for Grade 3, 6, and 9. Difficulty scales with each grade level.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#f5c518" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><polyline points="9 12 11 14 15 10"/></svg>,
|
||||||
|
title: '100% Free',
|
||||||
|
desc: 'No ads, no paywalls, no subscriptions. Ranked EQAO is completely free for every Ontario student.',
|
||||||
|
},
|
||||||
|
].map((f, i) => (
|
||||||
|
<div key={i} className="lp-feature-card">
|
||||||
|
<div style={{
|
||||||
|
width: 44, height: 44,
|
||||||
|
background: 'rgba(245,197,24,.07)', border: '1px solid rgba(245,197,24,.18)',
|
||||||
|
borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}>
|
||||||
|
{f.icon}
|
||||||
|
</div>
|
||||||
|
<h3 style={{
|
||||||
|
fontFamily: "'Barlow Condensed',sans-serif", fontSize: 18, fontWeight: 700,
|
||||||
|
letterSpacing: '0.05em', textTransform: 'uppercase', color: '#eceef8', marginBottom: 8,
|
||||||
|
}}>
|
||||||
|
{f.title}
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontFamily: "'Barlow',sans-serif", fontSize: 14, color: '#7a85a8', lineHeight: 1.65 }}>
|
||||||
|
{f.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── LEADERBOARD PREVIEW ───────────────────────────── */}
|
||||||
|
<section id="leaderboard" style={{
|
||||||
|
padding: '100px 0',
|
||||||
|
background: 'rgba(13,15,28,.78)',
|
||||||
|
backdropFilter: 'blur(2px)', WebkitBackdropFilter: 'blur(2px)',
|
||||||
|
}}>
|
||||||
|
<div className="lp-container">
|
||||||
|
<p className="lp-eyebrow">Rankings</p>
|
||||||
|
<h2 className="lp-section-title">Top Players</h2>
|
||||||
|
|
||||||
|
{/* Grade tabs */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginBottom: 28 }}>
|
||||||
|
{['G3', 'G6', 'G9'].map(g => (
|
||||||
|
<button
|
||||||
|
key={g}
|
||||||
|
onClick={() => setLbGrade(g)}
|
||||||
|
style={{
|
||||||
|
padding: '7px 24px',
|
||||||
|
background: lbGrade === g ? 'rgba(245,197,24,.12)' : 'rgba(17,20,38,.8)',
|
||||||
|
border: `1px solid ${lbGrade === g ? 'rgba(245,197,24,.5)' : 'rgba(255,255,255,.1)'}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
fontFamily: "'Barlow Condensed',sans-serif", fontSize: 13, fontWeight: 700,
|
||||||
|
letterSpacing: '0.1em', textTransform: 'uppercase',
|
||||||
|
color: lbGrade === g ? '#f5c518' : '#7a85a8',
|
||||||
|
cursor: 'pointer', transition: 'all .15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{g}
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lp-lb-card">
|
||||||
|
{/* Table header */}
|
||||||
|
<div style={{ display: 'flex', gap: 14, padding: '10px 20px', borderBottom: '1px solid rgba(255,255,255,.07)' }}>
|
||||||
|
{['#', 'Player', 'Best Time'].map((h, i) => (
|
||||||
|
<span key={h} style={{
|
||||||
|
fontFamily: "'Barlow Condensed',sans-serif", fontSize: 10, fontWeight: 700,
|
||||||
|
letterSpacing: '0.16em', textTransform: 'uppercase', color: '#7a85a8',
|
||||||
|
minWidth: i === 0 ? 36 : undefined, textAlign: i === 0 ? 'center' : undefined,
|
||||||
|
flex: i === 1 ? 1 : undefined,
|
||||||
|
}}>{h}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lbLoading ? (
|
||||||
|
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{[0, 1, 2, 3, 4].map(i => (
|
||||||
|
<div key={i} className="skel-pill" style={{ height: 44, animationDelay: `${i * 0.06}s` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : lbEntries.length === 0 ? (
|
||||||
|
<div style={{ padding: '48px 24px', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 28, marginBottom: 12, opacity: 0.4 }}>🏆</div>
|
||||||
|
<p style={{ fontFamily: "'Barlow Condensed',sans-serif", fontSize: 15, color: '#7a85a8' }}>No scores yet — be the first!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
lbEntries.map((entry, i) => {
|
||||||
|
const rc = i < 3 ? RANK_COLORS[i] : '#7a85a8';
|
||||||
|
const playerLabel = entry.username ? `@${entry.username}` : (entry.displayName ?? 'Player');
|
||||||
|
const initial = playerLabel[0]?.toUpperCase() ?? '?';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="lp-lb-row"
|
||||||
|
style={i === 0 ? { borderLeft: '2px solid #f5c518', paddingLeft: 18 } : {}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: "'Russo One',sans-serif", fontSize: 15, color: rc,
|
||||||
|
textShadow: i < 3 ? `0 0 12px ${rc}60` : 'none',
|
||||||
|
minWidth: 36, textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flex: 1, minWidth: 0 }}>
|
||||||
|
{entry.avatarUrl
|
||||||
|
? <img src={entry.avatarUrl} alt={playerLabel} referrerPolicy="no-referrer" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover', flexShrink: 0, border: i < 3 ? `1.5px solid ${rc}55` : '1px solid rgba(255,255,255,.1)' }} />
|
||||||
|
: <div style={{ width: 28, height: 28, borderRadius: '50%', background: '#181c32', border: '1px solid rgba(255,255,255,.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: "'Russo One',sans-serif", fontSize: 11, color: '#9ba5c4', flexShrink: 0 }}>{initial}</div>
|
||||||
|
}
|
||||||
|
<span style={{ fontFamily: "'Barlow Condensed',sans-serif", fontSize: 15, fontWeight: 600, color: '#eceef8', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{playerLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: "'Barlow Condensed',sans-serif", fontSize: 16, fontWeight: 700,
|
||||||
|
letterSpacing: '0.05em', color: i === 0 ? '#f5c518' : '#eceef8',
|
||||||
|
}}>
|
||||||
|
{fmtTime(entry.bestTime)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ textAlign: 'center', marginTop: 32 }}>
|
||||||
|
<p style={{ fontFamily: "'Barlow',sans-serif", fontSize: 15, color: '#7a85a8', marginBottom: 16 }}>
|
||||||
|
Think you can crack the top 5?
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => scrollTo('signin')}
|
||||||
|
style={{
|
||||||
|
padding: '12px 32px',
|
||||||
|
background: 'rgba(245,197,24,.08)', border: '1px solid rgba(245,197,24,.35)',
|
||||||
|
borderRadius: 8, fontFamily: "'Barlow Condensed',sans-serif",
|
||||||
|
fontSize: 14, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase',
|
||||||
|
color: '#f5c518', cursor: 'pointer', transition: 'background .2s, border-color .2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(245,197,24,.17)'; e.currentTarget.style.borderColor = 'rgba(245,197,24,.6)'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(245,197,24,.08)'; e.currentTarget.style.borderColor = 'rgba(245,197,24,.35)'; }}
|
||||||
|
>
|
||||||
|
Sign In to Compete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── SIGN IN ───────────────────────────────────────── */}
|
||||||
|
<section id="signin" style={{ padding: '100px 24px 120px' }}>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 44 }}>
|
||||||
|
<p className="lp-eyebrow">Join the Arena</p>
|
||||||
|
<h2 style={{
|
||||||
|
fontFamily: "'Russo One',sans-serif",
|
||||||
|
fontSize: 'clamp(30px,5vw,48px)',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.02em', color: '#eceef8',
|
||||||
|
}}>
|
||||||
|
Ready to Play?
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lp-signin-card">
|
||||||
|
<div className="login__cta" style={{ marginTop: 0 }}>
|
||||||
|
|
||||||
|
{authError && <p className="login__error">Sign-in failed — please try again.</p>}
|
||||||
|
|
||||||
|
{/* Google button */}
|
||||||
|
<a href={`${API}/auth/google`} className="btn-google" style={{ width: '100%', justifyContent: 'center', boxSizing: 'border-box' }}>
|
||||||
|
<GoogleIcon />Sign in with Google
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%' }}>
|
||||||
|
<div style={{ flex: 1, height: 1, background: 'rgba(255,255,255,.12)' }} />
|
||||||
|
<span style={{ fontFamily: "'Barlow Condensed',sans-serif", fontSize: 13, color: 'rgba(255,255,255,.35)', letterSpacing: '0.05em' }}>— or —</span>
|
||||||
|
<div style={{ flex: 1, height: 1, background: 'rgba(255,255,255,.12)' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode toggle */}
|
||||||
|
<div style={{ display: 'flex', width: '100%', background: 'rgba(255,255,255,.05)', borderRadius: 10, padding: 3, gap: 3, boxSizing: 'border-box' }}>
|
||||||
|
{['login', 'register'].map(mode => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { resetForm(); setAuthMode(mode); }}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '9px 0', border: 'none', borderRadius: 8,
|
||||||
|
fontSize: 14, fontWeight: 600,
|
||||||
|
fontFamily: "'Barlow Condensed',sans-serif", letterSpacing: '0.06em',
|
||||||
|
cursor: 'pointer', transition: 'all .15s',
|
||||||
|
background: authMode === mode ? 'rgba(255,255,255,.11)' : 'transparent',
|
||||||
|
color: authMode === mode ? '#fff' : 'rgba(255,255,255,.4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mode === 'login' ? 'Sign in' : 'Create account'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email form */}
|
||||||
|
<form onSubmit={handleEmailAuth} style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<input type="email" required placeholder="Email address" value={email} onChange={e => setEmail(e.target.value)} style={INPUT_STYLE} autoComplete="email" />
|
||||||
|
<input type="password" required placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} style={INPUT_STYLE} autoComplete={authMode === 'login' ? 'current-password' : 'new-password'} />
|
||||||
|
{authMode === 'register' && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Username (3–20 chars, letters/numbers/_)"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
pattern="^[a-zA-Z0-9_]{3,20}$"
|
||||||
|
title="3–20 characters: letters, numbers, underscores only"
|
||||||
|
style={INPUT_STYLE}
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '13px',
|
||||||
|
background: '#1a73e8', color: '#fff',
|
||||||
|
border: 'none', borderRadius: 10, fontSize: 15, fontWeight: 700,
|
||||||
|
fontFamily: "'Barlow Condensed',sans-serif", letterSpacing: '0.06em',
|
||||||
|
cursor: submitting ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: submitting ? 0.7 : 1, transition: 'opacity .2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submitting ? 'Please wait…' : authMode === 'login' ? 'Sign in' : 'Create account'}
|
||||||
|
</button>
|
||||||
|
{formError && <p className="login__error" style={{ margin: 0 }}>{formError}</p>}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p style={{ fontFamily: "'Barlow Condensed',sans-serif", fontSize: 12, letterSpacing: '0.05em', color: 'rgba(255,255,255,.3)', textAlign: 'center' }}>
|
||||||
|
Free · No credit card required
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── FOOTER ───────────────────────────────────────── */}
|
||||||
|
<footer style={{
|
||||||
|
background: 'rgba(7,8,15,.95)',
|
||||||
|
borderTop: '1px solid rgba(255,255,255,.07)',
|
||||||
|
padding: '40px 24px',
|
||||||
|
}}>
|
||||||
|
<div className="lp-container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 18 }}>
|
||||||
|
<span style={{ fontFamily: "'Russo One',sans-serif", fontSize: 16, letterSpacing: '0.04em', color: '#eceef8' }}>
|
||||||
|
RANKED<span style={{ color: '#f5c518' }}>EQAO</span>
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: 28, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||||
|
{[
|
||||||
|
{ label: 'How It Works', href: '#how-it-works' },
|
||||||
|
{ label: 'About', href: '/about' },
|
||||||
|
{ label: 'FAQ', href: '/faq' },
|
||||||
|
{ label: 'Sign In', href: null, onClick: () => scrollTo('signin') },
|
||||||
|
].map(({ label, href, onClick }) =>
|
||||||
|
href ? (
|
||||||
|
<a
|
||||||
|
key={label} href={href}
|
||||||
|
style={{ fontFamily: "'Barlow Condensed',sans-serif", fontSize: 12, letterSpacing: '0.12em', textTransform: 'uppercase', color: '#7a85a8', textDecoration: 'none', transition: 'color .2s' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = '#eceef8'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = '#7a85a8'}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={label} onClick={onClick}
|
||||||
|
style={{ fontFamily: "'Barlow Condensed',sans-serif", fontSize: 12, letterSpacing: '0.12em', textTransform: 'uppercase', color: '#7a85a8', background: 'none', border: 'none', cursor: 'pointer', padding: 0, transition: 'color .2s' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = '#eceef8'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = '#7a85a8'}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p style={{ fontFamily: "'Barlow',sans-serif", fontSize: 11, color: 'rgba(122,133,168,.45)', letterSpacing: '0.04em', textAlign: 'center' }}>
|
||||||
|
Ranked EQAO — Built for Ontario students · Not affiliated with EQAO or the Ontario government.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -141,22 +779,10 @@ export default function Login() {
|
|||||||
function GoogleIcon() {
|
function GoogleIcon() {
|
||||||
return (
|
return (
|
||||||
<svg className="btn-google__icon" viewBox="0 0 24 24" aria-hidden="true">
|
<svg className="btn-google__icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" />
|
||||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
|
||||||
fill="#4285F4"
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
|
||||||
/>
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
|
||||||
<path
|
|
||||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
|
||||||
fill="#34A853"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
|
||||||
fill="#FBBC05"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
|
||||||
fill="#EA4335"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+403
-25
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useNavigate, useLocation, Navigate } from 'react-router-dom';
|
import { useNavigate, useLocation, Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
const VALID_GRADES = new Set(['G3', 'G6', 'G9']);
|
const VALID_GRADES = new Set(['G3', 'G6', 'G9']);
|
||||||
@@ -9,21 +9,380 @@ function authHeaders() {
|
|||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function FindingScreen({ elapsed, onBack }) {
|
function getNameFromToken() {
|
||||||
|
try {
|
||||||
|
const token = sessionStorage.getItem('ranked_token');
|
||||||
|
if (!token) return 'You';
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
return payload.name ?? 'You';
|
||||||
|
} catch {
|
||||||
|
return 'You';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvatarFromToken() {
|
||||||
|
try {
|
||||||
|
const token = sessionStorage.getItem('ranked_token');
|
||||||
|
if (!token) return null;
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
return payload.picture ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatchFoundScreen({ myName, myAvatar, opponentName, opponentAvatar, grade, onDone }) {
|
||||||
|
const [count, setCount] = useState(5);
|
||||||
|
const [phase, setPhase] = useState('intro'); // intro → reveal → countdown
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t1 = setTimeout(() => setPhase('reveal'), 400);
|
||||||
|
const t2 = setTimeout(() => setPhase('countdown'), 1200);
|
||||||
|
return () => { clearTimeout(t1); clearTimeout(t2); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'countdown') return;
|
||||||
|
if (count <= 0) { onDone(); return; }
|
||||||
|
const id = setTimeout(() => setCount(c => c - 1), 1000);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [count, phase, onDone]);
|
||||||
|
|
||||||
|
const countColor = count <= 1 ? '#ff3b30' : count <= 2 ? '#ff9500' : count <= 3 ? '#ffcc00' : '#4fc3f7';
|
||||||
|
const countGlow = count <= 1 ? '#ff3b3088' : count <= 2 ? '#ff950088' : count <= 3 ? '#ffcc0088' : '#4fc3f788';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '100vh', display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: '#07090f',
|
||||||
|
fontFamily: "'system-ui',sans-serif", overflow: 'hidden', position: 'relative',
|
||||||
|
}}>
|
||||||
|
<style>{`
|
||||||
|
@keyframes bg-scan {
|
||||||
|
0% { transform: translateY(-100%); opacity: 0.07; }
|
||||||
|
100% { transform: translateY(100vh); opacity: 0.03; }
|
||||||
|
}
|
||||||
|
@keyframes header-drop {
|
||||||
|
0% { transform: translateY(-40px) scaleX(0.6); opacity: 0; letter-spacing: 0.3em; }
|
||||||
|
60% { transform: translateY(4px) scaleX(1.02); opacity: 1; letter-spacing: 0.18em; }
|
||||||
|
100% { transform: translateY(0) scaleX(1); opacity: 1; letter-spacing: 0.18em; }
|
||||||
|
}
|
||||||
|
@keyframes player-slide-left {
|
||||||
|
0% { transform: translateX(-120px); opacity: 0; }
|
||||||
|
70% { transform: translateX(6px); opacity: 1; }
|
||||||
|
100% { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes player-slide-right {
|
||||||
|
0% { transform: translateX(120px); opacity: 0; }
|
||||||
|
70% { transform: translateX(-6px); opacity: 1; }
|
||||||
|
100% { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes vs-slam {
|
||||||
|
0% { transform: scale(3) rotate(-8deg); opacity: 0; }
|
||||||
|
55% { transform: scale(0.92) rotate(1deg); opacity: 1; }
|
||||||
|
75% { transform: scale(1.06) rotate(-0.5deg); }
|
||||||
|
100% { transform: scale(1) rotate(0deg); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes divider-grow {
|
||||||
|
0% { width: 0; opacity: 0; }
|
||||||
|
100% { width: 100%; opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes count-slam {
|
||||||
|
0% { transform: scale(2.2); opacity: 0; }
|
||||||
|
50% { transform: scale(0.88); opacity: 1; }
|
||||||
|
75% { transform: scale(1.08); }
|
||||||
|
100% { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes ring-pulse {
|
||||||
|
0% { transform: scale(1); opacity: 0.7; }
|
||||||
|
100% { transform: scale(2.2); opacity: 0; }
|
||||||
|
}
|
||||||
|
@keyframes avatar-glow-blue {
|
||||||
|
0%, 100% { box-shadow: 0 0 18px 4px #1a73e8aa, 0 0 0 3px #1a73e8; }
|
||||||
|
50% { box-shadow: 0 0 32px 10px #1a73e8cc, 0 0 0 3px #1a73e8; }
|
||||||
|
}
|
||||||
|
@keyframes avatar-glow-red {
|
||||||
|
0%, 100% { box-shadow: 0 0 18px 4px #ff3b30aa, 0 0 0 3px #ff3b30; }
|
||||||
|
50% { box-shadow: 0 0 32px 10px #ff3b30cc, 0 0 0 3px #ff3b30; }
|
||||||
|
}
|
||||||
|
@keyframes scan-line {
|
||||||
|
0% { top: 0%; }
|
||||||
|
100% { top: 100%; }
|
||||||
|
}
|
||||||
|
.player-left { animation: player-slide-left 0.55s cubic-bezier(.22,1,.36,1) 0.9s both; }
|
||||||
|
.player-right { animation: player-slide-right 0.55s cubic-bezier(.22,1,.36,1) 0.9s both; }
|
||||||
|
.vs-text { animation: vs-slam 0.5s cubic-bezier(.22,1,.36,1) 1.05s both; }
|
||||||
|
.header-text { animation: header-drop 0.6s cubic-bezier(.22,1,.36,1) 0.1s both; }
|
||||||
|
.count-slam { animation: count-slam 0.3s cubic-bezier(.22,1,.36,1) both; }
|
||||||
|
.ring { animation: ring-pulse 0.7s ease-out both; }
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{/* Scanline overlay */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, pointerEvents: 'none', overflow: 'hidden', zIndex: 0,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', left: 0, right: 0, height: 2,
|
||||||
|
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.04), transparent)',
|
||||||
|
animation: 'scan-line 3s linear infinite',
|
||||||
|
}} />
|
||||||
|
{/* subtle grid */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0,
|
||||||
|
backgroundImage: 'linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px)',
|
||||||
|
backgroundSize: '40px 40px',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="header-text" style={{
|
||||||
|
fontSize: 13, fontWeight: 800, letterSpacing: '0.18em',
|
||||||
|
textTransform: 'uppercase', color: '#4fc3f7',
|
||||||
|
marginBottom: 48, zIndex: 1,
|
||||||
|
textShadow: '0 0 20px #4fc3f7aa',
|
||||||
|
}}>
|
||||||
|
Match Found
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Players row */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 48, zIndex: 1 }}>
|
||||||
|
|
||||||
|
{/* You */}
|
||||||
|
<div className="player-left" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 100, height: 100, borderRadius: '50%',
|
||||||
|
background: myAvatar ? 'transparent' : '#0d2a4a',
|
||||||
|
overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
animation: 'avatar-glow-blue 2s ease-in-out infinite',
|
||||||
|
}}>
|
||||||
|
{myAvatar
|
||||||
|
? <img src={myAvatar} alt={myName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
: <span style={{ fontSize: 38, color: '#4fc3f7', fontWeight: 800 }}>{(myName?.[0] ?? '?').toUpperCase()}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 15, fontWeight: 700, color: '#e8f4fd', maxWidth: 130, textAlign: 'center', wordBreak: 'break-word', letterSpacing: '0.01em' }}>{myName}</span>
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: '#1a73e8' }}>You</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* VS + countdown */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 20 }}>
|
||||||
|
<span className="vs-text" style={{
|
||||||
|
fontSize: 40, fontWeight: 900, color: '#fff',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
textShadow: '0 0 24px rgba(255,255,255,0.25)',
|
||||||
|
}}>VS</span>
|
||||||
|
|
||||||
|
{phase === 'countdown' && (
|
||||||
|
<div style={{ position: 'relative', width: 80, height: 80, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{/* pulse rings */}
|
||||||
|
<div key={`r1-${count}`} className="ring" style={{
|
||||||
|
position: 'absolute', width: 80, height: 80, borderRadius: '50%',
|
||||||
|
border: `2px solid ${countColor}`,
|
||||||
|
}} />
|
||||||
|
<div key={`r2-${count}`} className="ring" style={{
|
||||||
|
position: 'absolute', width: 80, height: 80, borderRadius: '50%',
|
||||||
|
border: `2px solid ${countColor}`,
|
||||||
|
animationDelay: '0.15s',
|
||||||
|
}} />
|
||||||
|
<div key={count} className="count-slam" style={{
|
||||||
|
width: 80, height: 80, borderRadius: '50%',
|
||||||
|
background: `radial-gradient(circle at 38% 38%, ${countColor}33, #0a0a1a)`,
|
||||||
|
border: `2px solid ${countColor}`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 38, fontWeight: 900, color: countColor,
|
||||||
|
boxShadow: `0 0 28px ${countGlow}, inset 0 0 12px ${countColor}22`,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}>
|
||||||
|
{count}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Opponent */}
|
||||||
|
<div className="player-right" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 100, height: 100, borderRadius: '50%',
|
||||||
|
background: opponentAvatar ? 'transparent' : '#2a0d0d',
|
||||||
|
overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
animation: 'avatar-glow-red 2s ease-in-out infinite',
|
||||||
|
}}>
|
||||||
|
{opponentAvatar
|
||||||
|
? <img src={opponentAvatar} alt={opponentName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
: <span style={{ fontSize: 38, color: '#ff6b6b', fontWeight: 800 }}>{(opponentName?.[0] ?? '?').toUpperCase()}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 15, fontWeight: 700, color: '#ffe8e8', maxWidth: 130, textAlign: 'center', wordBreak: 'break-word', letterSpacing: '0.01em' }}>{opponentName}</span>
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: '#ff3b30' }}>Opponent</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider line */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: 52, height: 1, zIndex: 1,
|
||||||
|
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.08), transparent)',
|
||||||
|
animation: 'divider-grow 0.6s ease-out 1.4s both',
|
||||||
|
width: 340,
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* Grade tag */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: 20, fontSize: 11, fontWeight: 700, letterSpacing: '0.15em',
|
||||||
|
textTransform: 'uppercase', color: 'rgba(255,255,255,0.2)', zIndex: 1,
|
||||||
|
animation: 'header-drop 0.5s ease-out 1.5s both',
|
||||||
|
}}>
|
||||||
|
Ranked · Grade {grade?.replace('G', '') ?? '?'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUEUE_DIAMONDS = [
|
||||||
|
{ left: '8%', top: '15%', size: 10, dur: '12s', delay: '0s' },
|
||||||
|
{ left: '22%', top: '68%', size: 7, dur: '15s', delay: '2s' },
|
||||||
|
{ left: '52%', top: '20%', size: 13, dur: '11s', delay: '1s' },
|
||||||
|
{ left: '68%', top: '72%', size: 9, dur: '14s', delay: '3s' },
|
||||||
|
{ left: '84%', top: '35%', size: 11, dur: '13s', delay: '0.5s' },
|
||||||
|
{ left: '40%', top: '82%', size: 8, dur: '10s', delay: '4s' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function FindingScreen({ elapsed, grade, onBack }) {
|
||||||
const [dots, setDots] = useState('');
|
const [dots, setDots] = useState('');
|
||||||
useEffect(() => { const t = setInterval(() => setDots(d => d.length >= 3 ? '' : d + '.'), 400); return () => clearInterval(t); }, []);
|
const [playersOnline, setPlayersOnline] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => setDots(d => d.length >= 3 ? '' : d + '.'), 400);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCount = () => {
|
||||||
|
fetch(`${API}/online-count`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => setPlayersOnline(d.online ?? 0))
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
fetchCount();
|
||||||
|
const t = setInterval(fetchCount, 30000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const minutes = Math.floor(elapsed / 60);
|
const minutes = Math.floor(elapsed / 60);
|
||||||
const seconds = elapsed % 60;
|
const seconds = elapsed % 60;
|
||||||
|
const gradeNum = grade?.replace('G', '') ?? '?';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#fff', fontFamily: 'system-ui,sans-serif', gap: 24 }}>
|
<div style={{ minHeight: '100vh', position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', fontFamily: 'system-ui,sans-serif', overflow: 'hidden' }}>
|
||||||
<div style={{ width: 64, height: 64, borderRadius: '50%', border: '4px solid #e0e0e0', borderTopColor: '#1a73e8', animation: 'spin 0.8s linear infinite' }} />
|
<style>{`
|
||||||
<h2 style={{ fontSize: 22, fontWeight: 600, color: '#333', margin: 0 }}>Searching for opponent{dots}</h2>
|
@keyframes spinCW { to { transform: rotate(360deg); } }
|
||||||
<p style={{ fontSize: 15, color: '#888', margin: 0 }}>Searching all players...</p>
|
@keyframes spinCCW { to { transform: rotate(-360deg); } }
|
||||||
<div style={{ fontSize: 14, color: '#666', fontFamily: 'system-ui,sans-serif', fontWeight: 500, letterSpacing: '0.02em' }}>
|
`}</style>
|
||||||
Time elapsed: {minutes}:{String(seconds).padStart(2, '0')}
|
|
||||||
|
{/* Arena background */}
|
||||||
|
<div className="arena-bg">
|
||||||
|
<div className="arena-grain" />
|
||||||
|
<div className="arena-shapes">
|
||||||
|
{QUEUE_DIAMONDS.map((d, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="shape shape--diamond"
|
||||||
|
style={{
|
||||||
|
left: d.left, top: d.top,
|
||||||
|
width: d.size, height: d.size,
|
||||||
|
animationDuration: d.dur, animationDelay: d.delay,
|
||||||
|
opacity: 0.35,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 24 }}>
|
||||||
|
|
||||||
|
{/* Two counter-rotating rings */}
|
||||||
|
<div style={{ position: 'relative', width: 80, height: 80 }}>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, borderRadius: '50%',
|
||||||
|
border: '3.5px solid rgba(245,197,24,0.12)',
|
||||||
|
borderTopColor: '#f5c518', borderRightColor: 'rgba(245,197,24,0.45)',
|
||||||
|
animation: 'spinCW 1.2s linear infinite',
|
||||||
|
}} />
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 13, borderRadius: '50%',
|
||||||
|
border: '3px solid rgba(79,195,247,0.12)',
|
||||||
|
borderTopColor: '#4fc3f7', borderLeftColor: 'rgba(79,195,247,0.45)',
|
||||||
|
animation: 'spinCCW 0.85s linear infinite',
|
||||||
|
}} />
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: '50%', left: '50%',
|
||||||
|
transform: 'translate(-50%,-50%)',
|
||||||
|
width: 7, height: 7, borderRadius: '50%',
|
||||||
|
background: '#f5c518',
|
||||||
|
boxShadow: '0 0 10px rgba(245,197,24,0.9)',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grade label */}
|
||||||
|
<p style={{
|
||||||
|
fontSize: 11, fontWeight: 700, letterSpacing: '0.22em',
|
||||||
|
textTransform: 'uppercase', color: 'rgba(245,197,24,0.45)',
|
||||||
|
margin: 0,
|
||||||
|
}}>
|
||||||
|
Grade {gradeNum} · Ranked
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Heading */}
|
||||||
|
<h2 style={{ fontSize: 22, fontWeight: 700, color: '#eceef8', margin: 0, letterSpacing: '0.01em' }}>
|
||||||
|
Searching for opponent{dots}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Players online */}
|
||||||
|
<p style={{
|
||||||
|
fontSize: 13, color: 'rgba(245,197,24,0.55)',
|
||||||
|
margin: 0, letterSpacing: '0.04em',
|
||||||
|
}}>
|
||||||
|
{playersOnline !== null ? `${playersOnline.toLocaleString()} ${playersOnline === 1 ? 'player' : 'players'} online` : 'finding players...'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Elapsed timer — large gold */}
|
||||||
|
<div style={{
|
||||||
|
fontSize: 48, fontWeight: 700,
|
||||||
|
color: '#f5c518', fontFamily: 'monospace',
|
||||||
|
letterSpacing: '0.06em', lineHeight: 1,
|
||||||
|
textShadow: '0 0 28px rgba(245,197,24,0.35)',
|
||||||
|
}}>
|
||||||
|
{String(minutes).padStart(2, '0')}:{String(seconds).padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Back button */}
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
marginTop: 8, padding: '10px 32px',
|
||||||
|
fontSize: 14, fontWeight: 600,
|
||||||
|
background: 'rgba(255,255,255,0.05)',
|
||||||
|
color: 'rgba(255,255,255,0.45)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.12)',
|
||||||
|
borderRadius: 999, cursor: 'pointer',
|
||||||
|
fontFamily: 'system-ui,sans-serif',
|
||||||
|
transition: 'background 0.15s, border-color 0.15s, color 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.background = 'rgba(255,255,255,0.09)';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.25)';
|
||||||
|
e.currentTarget.style.color = 'rgba(255,255,255,0.75)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.background = 'rgba(255,255,255,0.05)';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)';
|
||||||
|
e.currentTarget.style.color = 'rgba(255,255,255,0.45)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
‹ Back to Dashboard
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onBack} style={{ marginTop: 8, padding: '10px 32px', fontSize: 15, fontWeight: 600, background: '#f5f5f5', color: '#555', border: '1px solid #ccc', borderRadius: 999, cursor: 'pointer', fontFamily: 'system-ui,sans-serif' }}>‹ Back to Dashboard</button>
|
|
||||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -33,9 +392,10 @@ export default function Queue() {
|
|||||||
const { state } = useLocation();
|
const { state } = useLocation();
|
||||||
const grade = state?.grade ?? null;
|
const grade = state?.grade ?? null;
|
||||||
|
|
||||||
const [myName, setMyName] = useState('');
|
const [myName, setMyName] = useState(() => getNameFromToken());
|
||||||
const [myAvatar, setMyAvatar] = useState(null);
|
const [myAvatar, setMyAvatar] = useState(() => getAvatarFromToken());
|
||||||
const [queueElapsed, setQueueElapsed] = useState(0);
|
const [queueElapsed, setQueueElapsed] = useState(0);
|
||||||
|
const [matchData, setMatchData] = useState(null);
|
||||||
|
|
||||||
const pollRef = useRef(null);
|
const pollRef = useRef(null);
|
||||||
const joinedRef = useRef(false);
|
const joinedRef = useRef(false);
|
||||||
@@ -43,11 +403,12 @@ export default function Queue() {
|
|||||||
const leaveTimerRef = useRef(null);
|
const leaveTimerRef = useRef(null);
|
||||||
const leavingRef = useRef(false);
|
const leavingRef = useRef(false);
|
||||||
|
|
||||||
// Elapsed timer
|
// Elapsed timer — stop once matched
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (matchData) return;
|
||||||
const id = setInterval(() => setQueueElapsed(e => e + 1), 1000);
|
const id = setInterval(() => setQueueElapsed(e => e + 1), 1000);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, []);
|
}, [matchData]);
|
||||||
|
|
||||||
// Fetch own user info
|
// Fetch own user info
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -115,7 +476,7 @@ export default function Queue() {
|
|||||||
if (!data || leavingRef.current) return;
|
if (!data || leavingRef.current) return;
|
||||||
if (data.status === 'matched') {
|
if (data.status === 'matched') {
|
||||||
clearInterval(pollRef.current);
|
clearInterval(pollRef.current);
|
||||||
navigateToGame(data);
|
showMatchup(data);
|
||||||
} else if (data.status === 'not_in_queue') {
|
} else if (data.status === 'not_in_queue') {
|
||||||
clearInterval(pollRef.current);
|
clearInterval(pollRef.current);
|
||||||
fetch(`${API}/queue/join`, {
|
fetch(`${API}/queue/join`, {
|
||||||
@@ -130,7 +491,7 @@ export default function Queue() {
|
|||||||
.then(d => {
|
.then(d => {
|
||||||
if (!d || leavingRef.current) return;
|
if (!d || leavingRef.current) return;
|
||||||
if (d.status === 'waiting') pollQueue();
|
if (d.status === 'waiting') pollQueue();
|
||||||
else if (d.status === 'matched') navigateToGame(d);
|
else if (d.status === 'matched') showMatchup(d);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -144,29 +505,46 @@ export default function Queue() {
|
|||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (!leavingRef.current && data.status === 'matched') {
|
if (!leavingRef.current && data.status === 'matched') {
|
||||||
navigateToGame(data);
|
showMatchup(data);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateToGame(data) {
|
function showMatchup(data) {
|
||||||
clearInterval(pollRef.current);
|
clearInterval(pollRef.current);
|
||||||
completedRef.current = true;
|
completedRef.current = true;
|
||||||
|
setMatchData(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToGame = useCallback(() => {
|
||||||
navigate('/ranked-game', {
|
navigate('/ranked-game', {
|
||||||
state: {
|
state: {
|
||||||
gameId: data.gameId,
|
gameId: matchData.gameId,
|
||||||
questions: data.questions,
|
questions: matchData.questions,
|
||||||
opponentName: data.opponent.username ?? data.opponent.displayName,
|
opponentName: matchData.opponent.username ?? matchData.opponent.displayName,
|
||||||
opponentAvatar: data.opponent.avatarUrl,
|
opponentAvatar: matchData.opponent.avatarUrl,
|
||||||
myName,
|
myName,
|
||||||
myAvatar,
|
myAvatar,
|
||||||
grade,
|
grade,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}, [matchData, myName, myAvatar, grade, navigate]);
|
||||||
|
|
||||||
if (!VALID_GRADES.has(grade)) return <Navigate to="/dashboard" replace />;
|
if (!VALID_GRADES.has(grade)) return <Navigate to="/dashboard" replace />;
|
||||||
|
|
||||||
return <FindingScreen elapsed={queueElapsed} onBack={() => navigate('/dashboard')} />;
|
if (matchData) {
|
||||||
|
return (
|
||||||
|
<MatchFoundScreen
|
||||||
|
myName={myName}
|
||||||
|
myAvatar={myAvatar}
|
||||||
|
opponentName={matchData.opponent.username ?? matchData.opponent.displayName}
|
||||||
|
opponentAvatar={matchData.opponent.avatarUrl}
|
||||||
|
grade={grade}
|
||||||
|
onDone={goToGame}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <FindingScreen elapsed={queueElapsed} grade={grade} onBack={() => navigate('/dashboard')} />;
|
||||||
}
|
}
|
||||||
|
|||||||
+1124
-124
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
Prisma DB: npx prisma studio
|
Prisma DB for debugging: npx prisma studio
|
||||||
Express server: node src/index.js
|
Express server: node src/index.js
|
||||||
Run: cd frontend && npm run dev
|
Run: cd frontend && npm run dev
|
||||||
|
|
||||||
@@ -6,3 +6,9 @@ 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
|
||||||
|
|
||||||
|
john repomix: npx repomix --ignore "node_modules,frontend/node_modules,.git,uploads,prisma/migrations,frontend/public,frontend/dist,*.svg,*.png,*.jpg,*.jpeg,repomix-output.xml,frontend/src/assets/tree_bg_svg.svg"
|
||||||
|
ssh -D 9149 -i ssh-key-2026-05-19.key ubuntu@40.233.106.58
|
||||||
Generated
+63
@@ -10,9 +10,11 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^4.22.1",
|
"express": "^4.22.1",
|
||||||
|
"express-rate-limit": "^8.5.2",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
@@ -110,6 +112,20 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcrypt": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-addon-api": "^8.3.0",
|
||||||
|
"node-gyp-build": "^4.8.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.5",
|
"version": "1.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||||
@@ -421,6 +437,24 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-rate-limit": {
|
||||||
|
"version": "8.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
||||||
|
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ip-address": "^10.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/express-rate-limit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"express": ">= 4.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/finalhandler": {
|
"node_modules/finalhandler": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||||
@@ -591,6 +625,15 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ip-address": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -769,6 +812,26 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-addon-api": {
|
||||||
|
"version": "8.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz",
|
||||||
|
"integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || ^20 || >= 21"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/node-gyp-build": {
|
||||||
|
"version": "4.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||||
|
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build": "bin.js",
|
||||||
|
"node-gyp-build-optional": "optional.js",
|
||||||
|
"node-gyp-build-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/oauth": {
|
"node_modules/oauth": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz",
|
||||||
|
|||||||
@@ -19,9 +19,11 @@
|
|||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^4.22.1",
|
"express": "^4.22.1",
|
||||||
|
"express-rate-limit": "^8.5.2",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
|
|||||||
@@ -33,11 +33,12 @@ enum SessionStatus {
|
|||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
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?
|
||||||
|
passwordHash String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
+252
-27
@@ -5,6 +5,9 @@ const express = require('express');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
const passport = require('./auth/google');
|
const passport = require('./auth/google');
|
||||||
const authMiddleware = require('./middleware/auth');
|
const authMiddleware = require('./middleware/auth');
|
||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
@@ -13,7 +16,51 @@ const { calculateElo, DEFAULT_ELO } = require('./services/elo');
|
|||||||
const { generateStage } = require('./services/questions');
|
const { generateStage } = require('./services/questions');
|
||||||
|
|
||||||
const VALID_GRADES = new Set(['G3', 'G6', 'G9']);
|
const VALID_GRADES = new Set(['G3', 'G6', 'G9']);
|
||||||
|
|
||||||
|
const loginLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 10,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: 'too_many_attempts' },
|
||||||
|
skipSuccessfulRequests: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const registerLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 60 * 1000,
|
||||||
|
max: 5,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: 'too_many_attempts' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const authLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 20,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: 'too_many_attempts' },
|
||||||
|
});
|
||||||
const USERNAME_RE = /^[a-zA-Z0-9_]{3,20}$/;
|
const USERNAME_RE = /^[a-zA-Z0-9_]{3,20}$/;
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
function issueJwt(user) {
|
||||||
|
return jwt.sign(
|
||||||
|
{ userId: user.id, email: user.email, name: user.displayName, picture: user.avatarUrl ?? null },
|
||||||
|
process.env.JWT_SECRET,
|
||||||
|
{ algorithm: 'HS256', expiresIn: '7d' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateUniqueUsernameFromEmail(email) {
|
||||||
|
const base = (email.split('@')[0].replace(/\./g, '_').replace(/[^a-zA-Z0-9_]/g, '').slice(0, 15).toLowerCase()) || 'user';
|
||||||
|
if (!await db.user.findFirst({ where: { username: base } })) return base;
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const candidate = `${base}_${String(Math.floor(1000 + Math.random() * 9000))}`;
|
||||||
|
if (!await db.user.findFirst({ where: { username: candidate } })) return candidate;
|
||||||
|
}
|
||||||
|
return `${base}_${Date.now()}`;
|
||||||
|
}
|
||||||
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
|
||||||
|
|
||||||
@@ -32,6 +79,7 @@ function computeGameResult(game) {
|
|||||||
const p1 = game.players[players[1]];
|
const p1 = game.players[players[1]];
|
||||||
|
|
||||||
if (p0.finished && p1.finished) {
|
if (p0.finished && p1.finished) {
|
||||||
|
if (p0.timeSpentMs === p1.timeSpentMs) return players[Math.random() < 0.5 ? 0 : 1];
|
||||||
return p0.timeSpentMs < p1.timeSpentMs ? players[0] : players[1];
|
return p0.timeSpentMs < p1.timeSpentMs ? players[0] : players[1];
|
||||||
}
|
}
|
||||||
if (p0.finished) return players[0];
|
if (p0.finished) return players[0];
|
||||||
@@ -53,7 +101,16 @@ async function completeGame(game, forcedWinnerId = null) {
|
|||||||
game.winner = winnerId;
|
game.winner = winnerId;
|
||||||
game.completedAt = Date.now();
|
game.completedAt = Date.now();
|
||||||
|
|
||||||
// Snapshot both ELOs before any writes
|
// No winner (e.g. both disconnected) — skip ELO, just clean up
|
||||||
|
if (!winnerId) {
|
||||||
|
for (const uid of players) delete playerGames[uid];
|
||||||
|
game.status = 'completed';
|
||||||
|
setTimeout(() => { delete activeGames[game.id]; }, 30000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot both ELOs before any writes, then write atomically-as-possible
|
||||||
|
try {
|
||||||
const eloSnapshot = {};
|
const eloSnapshot = {};
|
||||||
for (const uid of players) {
|
for (const uid of players) {
|
||||||
const entry = await db.leaderboardEntry.findUnique({
|
const entry = await db.leaderboardEntry.findUnique({
|
||||||
@@ -97,6 +154,11 @@ async function completeGame(game, forcedWinnerId = null) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[completeGame] DB error during ELO write:', err);
|
||||||
|
// Clean up playerGames refs even on failure so players aren't stuck
|
||||||
|
for (const uid of players) delete playerGames[uid];
|
||||||
|
}
|
||||||
|
|
||||||
// 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';
|
||||||
@@ -106,7 +168,7 @@ async function completeGame(game, forcedWinnerId = null) {
|
|||||||
}, 30000);
|
}, 30000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup stale queue entries (30s no heartbeat) and old completed games every 30s
|
// Cleanup stale queue entries (30s no heartbeat) and old/ghost 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)) {
|
||||||
@@ -115,6 +177,18 @@ setInterval(() => {
|
|||||||
for (const [id, game] of Object.entries(activeGames)) {
|
for (const [id, game] of Object.entries(activeGames)) {
|
||||||
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];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Ghost game: all players have been silent for 60+ seconds — purge without ELO changes
|
||||||
|
if (game.status === 'active' && !game.completing) {
|
||||||
|
const allInactive = Object.values(game.players).every(
|
||||||
|
p => p.lastSeen && now - p.lastSeen > 60000,
|
||||||
|
);
|
||||||
|
if (allInactive) {
|
||||||
|
console.log(`[cleanup] purging ghost game ${id} — all players inactive >60s`);
|
||||||
|
for (const uid of Object.keys(game.players)) delete playerGames[uid];
|
||||||
|
delete activeGames[id];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
@@ -129,12 +203,12 @@ const UPLOADS_DIR = path.join(__dirname, '..', 'uploads');
|
|||||||
if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||||
app.use('/uploads', express.static(UPLOADS_DIR));
|
app.use('/uploads', express.static(UPLOADS_DIR));
|
||||||
|
|
||||||
app.get('/auth/google', passport.authenticate('google', {
|
app.get('/auth/google', authLimiter, passport.authenticate('google', {
|
||||||
scope: ['profile', 'email'],
|
scope: ['profile', 'email'],
|
||||||
session: false,
|
session: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.get('/auth/google/callback', (req, res, next) => {
|
app.get('/auth/google/callback', authLimiter, (req, res, next) => {
|
||||||
passport.authenticate('google', { session: false }, (err, result) => {
|
passport.authenticate('google', { session: false }, (err, result) => {
|
||||||
if (err || !result) {
|
if (err || !result) {
|
||||||
console.error('[auth/google/callback] failure:', err);
|
console.error('[auth/google/callback] failure:', err);
|
||||||
@@ -144,6 +218,51 @@ app.get('/auth/google/callback', (req, res, next) => {
|
|||||||
})(req, res, next);
|
})(req, res, next);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/auth/register', registerLimiter, async (req, res) => {
|
||||||
|
const { email, password, username } = req.body;
|
||||||
|
if (!email || !EMAIL_RE.test(email)) return res.status(400).json({ error: 'invalid_email' });
|
||||||
|
if (!password || password.length < 8) return res.status(400).json({ error: 'password_too_short' });
|
||||||
|
|
||||||
|
let finalUsername = username ? username.trim() : null;
|
||||||
|
if (finalUsername) {
|
||||||
|
if (!USERNAME_RE.test(finalUsername)) return res.status(400).json({ error: 'invalid_username' });
|
||||||
|
finalUsername = finalUsername.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (await db.user.findUnique({ where: { email } })) return res.status(409).json({ error: 'email_taken' });
|
||||||
|
if (!finalUsername) finalUsername = await generateUniqueUsernameFromEmail(email);
|
||||||
|
if (await db.user.findFirst({ where: { username: finalUsername } })) return res.status(409).json({ error: 'username_taken' });
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
const user = await db.user.create({
|
||||||
|
data: { email, displayName: finalUsername, username: finalUsername, passwordHash, avatarUrl: null },
|
||||||
|
});
|
||||||
|
res.json({ token: issueJwt(user) });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[auth/register]', err);
|
||||||
|
if (err.code === 'P2002') {
|
||||||
|
return res.status(409).json({ error: err.meta?.target?.includes('email') ? 'email_taken' : 'username_taken' });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: 'internal' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/auth/login', loginLimiter, async (req, res) => {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
if (!email || !password) return res.status(400).json({ error: 'missing_fields' });
|
||||||
|
try {
|
||||||
|
const user = await db.user.findUnique({ where: { email } });
|
||||||
|
if (!user) return res.status(401).json({ error: 'invalid_credentials' });
|
||||||
|
if (!user.passwordHash) return res.status(401).json({ error: 'use_google' });
|
||||||
|
if (!await bcrypt.compare(password, user.passwordHash)) return res.status(401).json({ error: 'invalid_credentials' });
|
||||||
|
res.json({ token: issueJwt(user) });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[auth/login]', err);
|
||||||
|
res.status(500).json({ error: 'internal' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/auth/me', authMiddleware, async (req, res) => {
|
app.get('/auth/me', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = await db.user.findUnique({ where: { id: req.user.userId } });
|
const user = await db.user.findUnique({ where: { id: req.user.userId } });
|
||||||
@@ -261,7 +380,7 @@ app.post('/game/complete', authMiddleware, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await upsertLeaderboardEntry(userId, grade, {
|
await upsertLeaderboardEntry(userId, grade, {
|
||||||
bestTime: typeof timeSpentMs === 'number' ? Math.round(timeSpentMs / 1000) : undefined,
|
bestTime: typeof timeSpentMs === 'number' ? timeSpentMs : undefined,
|
||||||
correctAnswers,
|
correctAnswers,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -319,7 +438,8 @@ app.post('/me/avatar', authMiddleware, async (req, res) => {
|
|||||||
const filepath = path.join(UPLOADS_DIR, filename);
|
const filepath = path.join(UPLOADS_DIR, filename);
|
||||||
try {
|
try {
|
||||||
await fs.promises.writeFile(filepath, buf);
|
await fs.promises.writeFile(filepath, buf);
|
||||||
const avatarUrl = `${req.protocol}://${req.get('host')}/uploads/${filename}`;
|
const base = process.env.BACKEND_URL ?? `${req.protocol}://${req.get('host')}`;
|
||||||
|
const avatarUrl = `${base}/uploads/${filename}`;
|
||||||
await db.user.update({ where: { id: req.user.userId }, data: { avatarUrl } });
|
await db.user.update({ where: { id: req.user.userId }, data: { avatarUrl } });
|
||||||
res.json({ avatarUrl });
|
res.json({ avatarUrl });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -389,6 +509,7 @@ app.post('/me/best-time', authMiddleware, async (req, res) => {
|
|||||||
where: { userId_grade: { userId: req.user.userId, grade } },
|
where: { userId_grade: { userId: req.user.userId, grade } },
|
||||||
});
|
});
|
||||||
const isNewBest = !entry || entry.bestTime === null || timeMs < entry.bestTime;
|
const isNewBest = !entry || entry.bestTime === null || timeMs < entry.bestTime;
|
||||||
|
console.log('[best-time] userId:', req.user.userId, 'grade:', grade, 'timeMs:', timeMs, 'isNewBest:', isNewBest);
|
||||||
const updated = await db.leaderboardEntry.upsert({
|
const updated = await db.leaderboardEntry.upsert({
|
||||||
where: { userId_grade: { userId: req.user.userId, grade } },
|
where: { userId_grade: { userId: req.user.userId, grade } },
|
||||||
create: { userId: req.user.userId, grade, elo: 800, bestTime: timeMs, gamesPlayed: 1 },
|
create: { userId: req.user.userId, grade, elo: 800, bestTime: timeMs, gamesPlayed: 1 },
|
||||||
@@ -417,6 +538,8 @@ app.post('/queue/join', authMiddleware, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (queue[req.user.userId]) {
|
if (queue[req.user.userId]) {
|
||||||
|
queue[req.user.userId].grade = grade;
|
||||||
|
queue[req.user.userId].lastSeen = Date.now();
|
||||||
return res.json({ status: 'waiting' });
|
return res.json({ status: 'waiting' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,12 +555,14 @@ app.post('/queue/join', authMiddleware, async (req, res) => {
|
|||||||
const gameGrade = opponentQueue.grade;
|
const gameGrade = opponentQueue.grade;
|
||||||
console.log(`[queue] matched ${req.user.userId} vs ${opponentId} for ${gameGrade}`);
|
console.log(`[queue] matched ${req.user.userId} vs ${opponentId} for ${gameGrade}`);
|
||||||
|
|
||||||
const questions = generateStage(gameGrade, 1);
|
const stage1 = generateStage(gameGrade, 1);
|
||||||
|
const stage2 = generateStage(gameGrade, 2);
|
||||||
const gameId = generateGameId();
|
const gameId = generateGameId();
|
||||||
const game = {
|
const game = {
|
||||||
id: gameId,
|
id: gameId,
|
||||||
grade: gameGrade,
|
grade: gameGrade,
|
||||||
questions,
|
stage1Questions: stage1,
|
||||||
|
stage2Questions: stage2,
|
||||||
players: {
|
players: {
|
||||||
[opponentId]: { displayName: opponentQueue.displayName, username: opponentQueue.username, avatarUrl: opponentQueue.avatarUrl, currentQuestion: 0, correctAnswers: 0, finished: false, finishedAt: null, timeSpentMs: null, eloChange: 0, newElo: 800, lastSeen: Date.now() },
|
[opponentId]: { displayName: opponentQueue.displayName, username: opponentQueue.username, avatarUrl: opponentQueue.avatarUrl, currentQuestion: 0, correctAnswers: 0, finished: false, finishedAt: null, timeSpentMs: null, eloChange: 0, newElo: 800, lastSeen: Date.now() },
|
||||||
[req.user.userId]: { displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl, currentQuestion: 0, correctAnswers: 0, finished: false, finishedAt: null, timeSpentMs: null, eloChange: 0, newElo: 800, lastSeen: Date.now() },
|
[req.user.userId]: { displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl, currentQuestion: 0, correctAnswers: 0, finished: false, finishedAt: null, timeSpentMs: null, eloChange: 0, newElo: 800, lastSeen: Date.now() },
|
||||||
@@ -446,12 +571,13 @@ app.post('/queue/join', authMiddleware, async (req, res) => {
|
|||||||
winner: null,
|
winner: null,
|
||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
|
chatMessages: [],
|
||||||
};
|
};
|
||||||
activeGames[gameId] = game;
|
activeGames[gameId] = game;
|
||||||
playerGames[opponentId] = gameId;
|
playerGames[opponentId] = gameId;
|
||||||
playerGames[req.user.userId] = gameId;
|
playerGames[req.user.userId] = gameId;
|
||||||
|
|
||||||
return res.json({ status: 'matched', gameId });
|
return res.json({ status: 'matched', gameId, questions: { stage1: game.stage1Questions, stage2: game.stage2Questions } });
|
||||||
}
|
}
|
||||||
|
|
||||||
queue[req.user.userId] = { grade, joinedAt: Date.now(), displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl, lastSeen: Date.now() };
|
queue[req.user.userId] = { grade, joinedAt: Date.now(), displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl, lastSeen: Date.now() };
|
||||||
@@ -480,7 +606,11 @@ app.delete('/queue/leave', authMiddleware, async (req, res) => {
|
|||||||
player.correctAnswers = 0;
|
player.correctAnswers = 0;
|
||||||
player.finished = true;
|
player.finished = true;
|
||||||
player.finishedAt = Date.now();
|
player.finishedAt = Date.now();
|
||||||
|
try {
|
||||||
await completeGame(game, opponentId);
|
await completeGame(game, opponentId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[queue/leave] completeGame error:', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -497,7 +627,7 @@ app.get('/queue/debug', authMiddleware, (_req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/queue/status', authMiddleware, async (req, res) => {
|
app.get('/queue/status', authMiddleware, (req, res) => {
|
||||||
const gameId = playerGames[req.user.userId];
|
const gameId = playerGames[req.user.userId];
|
||||||
|
|
||||||
if (gameId) {
|
if (gameId) {
|
||||||
@@ -518,15 +648,13 @@ app.get('/queue/status', authMiddleware, async (req, res) => {
|
|||||||
return res.json({ status: 'not_in_queue' });
|
return res.json({ status: 'not_in_queue' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const opponentUser = await db.user.findUnique({ where: { id: opponentId }, select: { username: true, displayName: true, avatarUrl: true } });
|
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
status: 'matched',
|
status: 'matched',
|
||||||
gameId,
|
gameId,
|
||||||
questions: game.questions,
|
questions: { stage1: game.stage1Questions, stage2: game.stage2Questions },
|
||||||
opponent: {
|
opponent: {
|
||||||
username: opponentUser?.username ?? opponentUser?.displayName ?? opponent.displayName,
|
username: opponent.username ?? opponent.displayName,
|
||||||
avatarUrl: opponentUser?.avatarUrl ?? opponent.avatarUrl,
|
avatarUrl: opponent.avatarUrl ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -566,13 +694,17 @@ app.post('/game/forfeit', authMiddleware, async (req, res) => {
|
|||||||
game.abandonTimeout = null;
|
game.abandonTimeout = null;
|
||||||
|
|
||||||
const opponentId = Object.keys(game.players).find(id => id !== req.user.userId);
|
const opponentId = Object.keys(game.players).find(id => id !== req.user.userId);
|
||||||
completeGame(game, opponentId);
|
try {
|
||||||
|
await completeGame(game, opponentId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[game/forfeit] completeGame error:', err);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true, eloChange: player.eloChange ?? 0, newElo: player.newElo ?? 800 });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/game/progress', authMiddleware, (req, res) => {
|
app.post('/game/progress', authMiddleware, async (req, res) => {
|
||||||
const { gameId, currentQuestion, correctAnswers, finished, timeSpentMs } = req.body;
|
const { gameId, currentQuestion, correctAnswers, finished, timeSpentMs, stage } = req.body;
|
||||||
const game = activeGames[gameId];
|
const game = activeGames[gameId];
|
||||||
|
|
||||||
if (!game || game.status !== 'active') {
|
if (!game || game.status !== 'active') {
|
||||||
@@ -594,31 +726,96 @@ app.post('/game/progress', authMiddleware, (req, res) => {
|
|||||||
if (correctAnswers !== undefined) {
|
if (correctAnswers !== undefined) {
|
||||||
player.correctAnswers = Math.max(0, Math.min(STAGE_SIZE, Math.floor(Number(correctAnswers))));
|
player.correctAnswers = Math.max(0, Math.min(STAGE_SIZE, Math.floor(Number(correctAnswers))));
|
||||||
}
|
}
|
||||||
|
if (stage !== undefined) player.currentStage = Number(stage) || 1;
|
||||||
|
|
||||||
if (finished) {
|
if (finished && stage === 1) {
|
||||||
|
player.stage1Finished = true;
|
||||||
|
player.stage1CorrectCount = player.correctAnswers ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finished && stage === 2) {
|
||||||
const elapsed = Date.now() - game.startedAt;
|
const elapsed = Date.now() - game.startedAt;
|
||||||
const claimed = typeof timeSpentMs === 'number' ? timeSpentMs : elapsed;
|
const claimed = typeof timeSpentMs === 'number' ? timeSpentMs : elapsed;
|
||||||
// Clamp: no faster than minimum human time, no slower than actual elapsed + small buffer
|
// Clamp: no faster than minimum human time, no slower than actual elapsed + small buffer
|
||||||
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.stage2Finished = true;
|
||||||
player.finished = true;
|
player.finished = true;
|
||||||
player.finishedAt = Date.now();
|
player.finishedAt = Date.now();
|
||||||
player.completedQuiz = true;
|
player.completedQuiz = true;
|
||||||
|
|
||||||
const winner = computeGameResult(game);
|
const winner = computeGameResult(game);
|
||||||
if (winner) {
|
if (winner) {
|
||||||
completeGame(game);
|
try {
|
||||||
|
await completeGame(game);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[game/progress] completeGame error:', err);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Opponent hasn't finished — award win to this player after 5 minutes if game still active
|
// Opponent hasn't finished — award win to this player after 2 minutes if game still active
|
||||||
const finishedUserId = req.user.userId;
|
const finishedUserId = req.user.userId;
|
||||||
game.abandonTimeout = setTimeout(() => {
|
game.abandonTimeout = setTimeout(async () => {
|
||||||
if (game.status === 'active') completeGame(game, finishedUserId);
|
if (game.status === 'active') {
|
||||||
}, 5 * 60 * 1000);
|
try {
|
||||||
|
await completeGame(game, finishedUserId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[game/progress] abandonTimeout completeGame error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 2 * 60 * 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/game/emote', authMiddleware, (req, res) => {
|
||||||
|
const { gameId, emote } = req.body;
|
||||||
|
if (!gameId) return res.status(400).json({ error: 'gameId required' });
|
||||||
|
if (!emote || typeof emote !== 'string' || !emote.trim() || emote.length > 20) {
|
||||||
|
return res.status(400).json({ error: 'invalid emote' });
|
||||||
|
}
|
||||||
|
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' });
|
||||||
|
player.lastEmote = { text: emote.trim(), sentAt: Date.now() };
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/game/chat', authMiddleware, (req, res) => {
|
||||||
|
const { gameId, message } = req.body;
|
||||||
|
if (!gameId) return res.status(400).json({ error: 'gameId required' });
|
||||||
|
if (!message || typeof message !== 'string' || !message.trim()) {
|
||||||
|
return res.status(400).json({ error: 'invalid message' });
|
||||||
|
}
|
||||||
|
const trimmed = message.trim().slice(0, 120);
|
||||||
|
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' });
|
||||||
|
|
||||||
|
if (!player.chatMsgTimes) player.chatMsgTimes = [];
|
||||||
|
const now = Date.now();
|
||||||
|
player.chatMsgTimes = player.chatMsgTimes.filter(t => now - t < 1000);
|
||||||
|
if (player.chatMsgTimes.length >= 4) return res.status(429).json({ error: 'slow_down' });
|
||||||
|
player.chatMsgTimes.push(now);
|
||||||
|
|
||||||
|
game.chatMessages.push({
|
||||||
|
userId: req.user.userId,
|
||||||
|
username: player.username ?? player.displayName,
|
||||||
|
text: trimmed,
|
||||||
|
sentAt: now,
|
||||||
|
elapsedMs: now - game.startedAt,
|
||||||
|
});
|
||||||
|
if (game.chatMessages.length > 50) game.chatMessages.shift();
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/game/status/:gameId', authMiddleware, async (req, res) => {
|
app.get('/game/status/:gameId', authMiddleware, async (req, res) => {
|
||||||
const game = activeGames[req.params.gameId];
|
const game = activeGames[req.params.gameId];
|
||||||
if (!game) {
|
if (!game) {
|
||||||
@@ -639,11 +836,15 @@ app.get('/game/status/:gameId', authMiddleware, async (req, res) => {
|
|||||||
const opponentInactive = opponent.lastSeen && Date.now() - opponent.lastSeen > 20000;
|
const opponentInactive = opponent.lastSeen && Date.now() - opponent.lastSeen > 20000;
|
||||||
if (game.status === 'active' && !player.finished && opponentInactive) {
|
if (game.status === 'active' && !player.finished && opponentInactive) {
|
||||||
game.disconnectedPlayer = opponentId;
|
game.disconnectedPlayer = opponentId;
|
||||||
|
try {
|
||||||
await completeGame(game, req.user.userId);
|
await completeGame(game, req.user.userId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[game/status] completeGame error:', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const opponentUser = await db.user.findUnique({ where: { id: opponentId }, select: { username: true, displayName: true } });
|
// Use cached name — avoids a DB query on every 1-second poll
|
||||||
const opponentName = opponentUser?.username ?? opponentUser?.displayName ?? opponent.displayName;
|
const opponentName = opponent.username ?? opponent.displayName;
|
||||||
|
|
||||||
const opponentDced = game.disconnectedPlayer === opponentId;
|
const opponentDced = game.disconnectedPlayer === opponentId;
|
||||||
const youDced = game.disconnectedPlayer === req.user.userId;
|
const youDced = game.disconnectedPlayer === req.user.userId;
|
||||||
@@ -664,6 +865,14 @@ app.get('/game/status/:gameId', authMiddleware, async (req, res) => {
|
|||||||
timeSpentMs: player.timeSpentMs,
|
timeSpentMs: player.timeSpentMs,
|
||||||
eloChange: player.eloChange,
|
eloChange: player.eloChange,
|
||||||
newElo: player.newElo,
|
newElo: player.newElo,
|
||||||
|
currentStage: player.currentStage ?? 1,
|
||||||
|
stage1Finished: player.stage1Finished ?? false,
|
||||||
|
stage: player.currentStage ?? 1,
|
||||||
|
savedProgress: {
|
||||||
|
currentQuestion: player.currentQuestion ?? 0,
|
||||||
|
correctAnswers: player.correctAnswers ?? 0,
|
||||||
|
stage: player.currentStage ?? 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
opponent: {
|
opponent: {
|
||||||
username: opponentName,
|
username: opponentName,
|
||||||
@@ -671,11 +880,27 @@ app.get('/game/status/:gameId', authMiddleware, async (req, res) => {
|
|||||||
correctAnswers: opponent.correctAnswers,
|
correctAnswers: opponent.correctAnswers,
|
||||||
finished: opponent.finished,
|
finished: opponent.finished,
|
||||||
timeSpentMs: opponent.timeSpentMs,
|
timeSpentMs: opponent.timeSpentMs,
|
||||||
|
currentStage: opponent.currentStage ?? 1,
|
||||||
|
stage1Finished: opponent.stage1Finished ?? false,
|
||||||
|
lastEmote: (opponent.lastEmote && Date.now() - opponent.lastEmote.sentAt < 4000) ? opponent.lastEmote.text : null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
chatMessages: game.chatMessages ?? [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/online-count', (_req, res) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const inQueue = Object.values(queue).filter(q => now - q.lastSeen <= 30000).length;
|
||||||
|
const inGame = Object.entries(playerGames).filter(([uid, gameId]) => {
|
||||||
|
const game = activeGames[gameId];
|
||||||
|
if (!game || game.status !== 'active') return false;
|
||||||
|
const p = game.players[uid];
|
||||||
|
return p && p.lastSeen && now - p.lastSeen <= 30000;
|
||||||
|
}).length;
|
||||||
|
res.json({ online: inQueue + inGame });
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ async function getLeaderboard(grade, sortBy = 'elo', limit = 10) {
|
|||||||
orderBy,
|
orderBy,
|
||||||
take: limit,
|
take: limit,
|
||||||
include: {
|
include: {
|
||||||
user: { select: { displayName: true, avatarUrl: true } },
|
user: { select: { displayName: true, avatarUrl: true, username: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return entries.map((entry, index) => ({ ...entry, rank: index + 1 }));
|
return entries.map((entry, index) => ({ ...entry, ...entry.user, rank: index + 1 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getUserRank(userId, grade, sortBy = 'elo') {
|
async function getUserRank(userId, grade, sortBy = 'elo') {
|
||||||
|
|||||||
Reference in New Issue
Block a user