From 02ac5f95937ceef56ea419db1b2ebe6835e4a470 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 11 Jul 2026 11:13:28 -0400 Subject: [PATCH] leaderboard and ranked systems --- .DS_Store | Bin 0 -> 8196 bytes .claude/CLAUDE.md | 142 + .gitignore | 3 + README.md | 1 + frontend/.gitignore | 24 + frontend/index.html | 19 + frontend/package-lock.json | 962 +++ frontend/package.json | 21 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.jsx | 46 + frontend/src/assets/tree_bg_svg.svg | 5356 +++++++++++++++++ frontend/src/components/LeaderboardModal.jsx | 118 + frontend/src/components/ProfileDropdown.jsx | 55 + frontend/src/context/ThemeContext.jsx | 34 + frontend/src/index.css | 1534 +++++ frontend/src/main.jsx | 10 + frontend/src/pages/Dashboard.jsx | 405 ++ frontend/src/pages/Game.jsx | 468 ++ frontend/src/pages/Login.jsx | 162 + frontend/src/pages/Queue.jsx | 505 ++ frontend/src/pages/Settings.jsx | 42 + frontend/tsconfig.json | 19 + frontend/vite.config.js | 6 + notes.txt | 11 + package-lock.json | 1180 ++++ package.json | 29 + .../20260507153558_init/migration.sql | 102 + .../20260507153955_grade_enum/migration.sql | 25 + .../migration.sql | 12 + .../20260525000000_add_username/migration.sql | 5 + .../migration.sql | 26 + .../migration.sql | 1 + .../migration.sql | 2 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 105 + src/.DS_Store | Bin 0 -> 6148 bytes src/auth/google.js | 78 + src/db.js | 5 + src/index.js | 559 ++ src/middleware/auth.js | 19 + src/services/elo.js | 16 + src/services/leaderboard.js | 73 + src/services/questions.js | 110 + 44 files changed, 12318 insertions(+) create mode 100644 .DS_Store create mode 100644 .claude/CLAUDE.md create mode 100644 .gitignore create mode 100644 README.md create mode 100644 frontend/.gitignore create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/assets/tree_bg_svg.svg create mode 100644 frontend/src/components/LeaderboardModal.jsx create mode 100644 frontend/src/components/ProfileDropdown.jsx create mode 100644 frontend/src/context/ThemeContext.jsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/Dashboard.jsx create mode 100644 frontend/src/pages/Game.jsx create mode 100644 frontend/src/pages/Login.jsx create mode 100644 frontend/src/pages/Queue.jsx create mode 100644 frontend/src/pages/Settings.jsx create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.js create mode 100644 notes.txt create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 prisma/migrations/20260507153558_init/migration.sql create mode 100644 prisma/migrations/20260507153955_grade_enum/migration.sql create mode 100644 prisma/migrations/20260511120000_add_leaderboard_score/migration.sql create mode 100644 prisma/migrations/20260525000000_add_username/migration.sql create mode 100644 prisma/migrations/20260525000001_rebuild_leaderboard_entry/migration.sql create mode 100644 prisma/migrations/20260525000002_elo_default_800/migration.sql create mode 100644 prisma/migrations/20260525164258_apply_pending/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 src/.DS_Store create mode 100644 src/auth/google.js create mode 100644 src/db.js create mode 100644 src/index.js create mode 100644 src/middleware/auth.js create mode 100644 src/services/elo.js create mode 100644 src/services/leaderboard.js create mode 100644 src/services/questions.js diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..f105b0a5b0f466e38b10d97653adfd2c5fd98116 GIT binary patch literal 8196 zcmeHMPm9w)6o1p!ZP!KY!V2{w176G8u0>hA#9HsN5e5HHlP2y4(@bendnko?Rd0d^ z&x&8cZ{WfE-UPveAHs|8&5YZ$X+7!S9TB?`U8x>1K%CT12@cBn+3ywVZbn87%&VN2L1&G@XRKutT^|T*35UaFQ(mVhNX};5GIE(uZrbG^PrrE8bIe4@|o<-C{81j(Se! zh?d4wp>hYN+=1!HOm`?uMn^v`&4F18&1@Jj4CEQ0-n|UF(1Q@1Z2vA?)O>IUVGt*c z#xW}tOQ$xpKc2gx<9WS_fFuCyVac=B1)Vf@`utP@O(6R za5soNF>Z@dKv|xBmbd7OPR9-9zAFXqd-eU7kJp!3dB0&bILb!j z6m)^*r}i)kMRbT7+C>E(!w>{SkbsY~r{1EheHC*1%GRND@9oQFd(T$lAi8k+)v>HZ z?p}Q{tHrPEvehUh97E>$UZ}?g46q&n#Lx#Pn=`=q&*(p=&*rPx)ol;=1e;UFQq nRH1aiY3_#rem?ob5U)9!l8>b^RfsDn=0$*{!AypMzskT5IAaZ> literal 0 HcmV?d00001 diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..ffbe5f8 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,142 @@ +# 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 # 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..883bc3b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +.DS_Store +.claude diff --git a/README.md b/README.md new file mode 100644 index 0000000..bfe54d5 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +RANKED EQAO \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..33dbfd4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,19 @@ + + + + + + Ranked EQAO + + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..afbbba9 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,962 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@vitejs/plugin-react": "^6.0.1", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router-dom": "^7.15.0" + }, + "devDependencies": { + "typescript": "~6.0.2", + "vite": "^8.0.10" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.128.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz", + "integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz", + "integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz", + "integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", + "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitejs/plugin-react/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-router": { + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz", + "integrity": "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.0.tgz", + "integrity": "sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.15.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz", + "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.128.0", + "@rolldown/pluginutils": "1.0.0-rc.18" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-x64": "1.0.0-rc.18", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz", + "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.0-rc.18", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..fe2831e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "~6.0.2", + "vite": "^8.0.10" + }, + "dependencies": { + "@vitejs/plugin-react": "^6.0.1", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router-dom": "^7.15.0" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..8ed0a16 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,46 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { ThemeProvider } from './context/ThemeContext'; +import Login from './pages/Login'; +import Dashboard from './pages/Dashboard'; +import Game from './pages/Game'; +import Queue from './pages/Queue'; +import Settings from './pages/Settings'; + +function getValidStoredToken() { + const token = sessionStorage.getItem('ranked_token'); + if (!token) return null; + try { + const { exp } = JSON.parse(atob(token.split('.')[1])); + if (exp * 1000 <= Date.now()) { + sessionStorage.removeItem('ranked_token'); + return null; + } + return token; + } catch { + sessionStorage.removeItem('ranked_token'); + return null; + } +} + +// OAuth callback lands at /dashboard?token= — token is in the URL, not yet +// in sessionStorage, so we must accept it here or the guard bounces the user back to /. +function hasUrlToken() { + return !!new URLSearchParams(window.location.search).get('token'); +} + +export default function App() { + const loggedIn = !!getValidStoredToken(); + return ( + + + + : } /> + : } /> + : } /> + : } /> + : } /> + + + + ); +} diff --git a/frontend/src/assets/tree_bg_svg.svg b/frontend/src/assets/tree_bg_svg.svg new file mode 100644 index 0000000..fc4aa03 --- /dev/null +++ b/frontend/src/assets/tree_bg_svg.svg @@ -0,0 +1,5356 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/LeaderboardModal.jsx b/frontend/src/components/LeaderboardModal.jsx new file mode 100644 index 0000000..1cfd64d --- /dev/null +++ b/frontend/src/components/LeaderboardModal.jsx @@ -0,0 +1,118 @@ +import { useEffect, useState } from 'react'; + +const API = import.meta.env.VITE_API_URL; + +const GRADES = [ + { id: 'G3', label: 'Grade 3' }, + { id: 'G6', label: 'Grade 6' }, + { id: 'G9', label: 'Grade 9' }, +]; + +function fmtTime(seconds) { + if (seconds == null) return '—'; + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${String(s).padStart(2, '0')}`; +} + +function TrophyIcon() { + return ( + + + + + + + + + ); +} + +export default function LeaderboardModal({ isOpen, onClose }) { + const [selectedGrade, setSelectedGrade] = useState('G3'); + const [leaderboard, setLeaderboard] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!isOpen) return; + setLoading(true); + fetch(`${API}/leaderboard?grade=${selectedGrade}&sortBy=bestTime&limit=10`) + .then(res => res.json()) + .then(data => setLeaderboard(Array.isArray(data) ? data : [])) + .catch(() => setLeaderboard([])) + .finally(() => setLoading(false)); + }, [isOpen, selectedGrade]); + + useEffect(() => { + if (!isOpen) return; + const handler = (e) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const MEDALS = ['🥇', '🥈', '🥉']; + + return ( +
+
e.stopPropagation()}> +
+
+ + + +

Best Times

+
+ +
+ +
+ {GRADES.map(g => ( + + ))} +
+ +
+ {loading ? ( +
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+ ) : leaderboard.length === 0 ? ( +
+ 🏆 +

No scores yet

+

Be the first to set a record

+
+ ) : ( +
+ {leaderboard.map(entry => ( +
+ 3 ? ' lb-rank--num' : ''}`}> + {entry.rank <= 3 ? MEDALS[entry.rank - 1] : entry.rank} + +
+ {entry.avatarUrl + ? {entry.displayName} + :
{entry.displayName[0]?.toUpperCase() ?? '?'}
+ } + {entry.displayName} +
+ {fmtTime(entry.bestTime)} +
+ ))} +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/ProfileDropdown.jsx b/frontend/src/components/ProfileDropdown.jsx new file mode 100644 index 0000000..49ec3df --- /dev/null +++ b/frontend/src/components/ProfileDropdown.jsx @@ -0,0 +1,55 @@ +import { useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +export default function ProfileDropdown({ displayName, avatarUrl, initial, onSignOut }) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const navigate = useNavigate(); + + useEffect(() => { + if (!open) return; + const onKey = (e) => { if (e.key === 'Escape') setOpen(false); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open]); + + useEffect(() => { + if (!open) return; + const onClick = (e) => { + if (ref.current && !ref.current.contains(e.target)) setOpen(false); + }; + window.addEventListener('mousedown', onClick); + return () => window.removeEventListener('mousedown', onClick); + }, [open]); + + return ( +
+ + + {open && ( +
+
+ {avatarUrl + ? {displayName} + :
{initial}
+ } + {displayName} +
+
+ + +
+ )} +
+ ); +} diff --git a/frontend/src/context/ThemeContext.jsx b/frontend/src/context/ThemeContext.jsx new file mode 100644 index 0000000..644ad83 --- /dev/null +++ b/frontend/src/context/ThemeContext.jsx @@ -0,0 +1,34 @@ +import { createContext, useContext, useEffect, useState } from 'react'; + +const THEME_KEY = 'ranked_theme'; + +function getInitialTheme() { + try { + const stored = localStorage.getItem(THEME_KEY); + if (stored === 'light' || stored === 'dark') return stored; + } catch {} + return 'dark'; +} + +const ThemeContext = createContext(); + +export function ThemeProvider({ children }) { + const [theme, setTheme] = useState(getInitialTheme); + + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme); + try { localStorage.setItem(THEME_KEY, theme); } catch {} + }, [theme]); + + const toggle = () => setTheme(t => t === 'dark' ? 'light' : 'dark'); + + return ( + + {children} + + ); +} + +export function useTheme() { + return useContext(ThemeContext); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..e55af57 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,1534 @@ +@import url('https://fonts.googleapis.com/css2?family=Russo+One&family=Barlow+Condensed:wght@300;400;500;600;700&family=Barlow:wght@400;500&display=swap'); + +/* ── Variables ────────────────────────────────────────────── */ +:root { + --bg: #07080f; + --bg-2: #0d0f1c; + --surface: #111426; + --surface-2: #181c32; + --border: rgba(255, 255, 255, 0.07); + --border-glow: rgba(245, 197, 24, 0.25); + + --gold: #f5c518; + --gold-dim: rgba(245, 197, 24, 0.12); + --gold-mid: rgba(245, 197, 24, 0.4); + --blue: #2563eb; + --blue-bright: #3b82f6; + --blue-dim: rgba(37, 99, 235, 0.15); + + --bronze: #c9824a; + --silver: #94a3b8; + --platinum: #f5c518; + + --text: #e8eaf6; + --text-muted: #4b5270; + --text-dim: #6b7491; + + --font-display: 'Russo One', sans-serif; + --font-ui: 'Barlow Condensed', sans-serif; + --font-body: 'Barlow', sans-serif; + + --radius-sm: 4px; + --radius: 8px; + --radius-lg: 16px; +} + +/* ── Light mode ────────────────────────────────────────────── */ +[data-theme="light"] { + --bg: #f0f2f5; + --bg-2: #e4e7eb; + --surface: #ffffff; + --surface-2: #f0f2f5; + --border: rgba(0, 0, 0, 0.1); + --border-glow: rgba(245, 197, 24, 0.15); + --gold-dim: rgba(245, 197, 24, 0.2); + --gold-mid: rgba(245, 197, 24, 0.5); + + --text: #1a1a2e; + --text-muted: #8892b0; + --text-dim: #a0a8c0; + + --blue-dim: rgba(37, 99, 235, 0.1); +} + +[data-theme="light"] .arena-bg::after { + opacity: 0.3; +} + +[data-theme="light"] .arena-bg::before { + opacity: 0.4; +} + +/* ── Reset ────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html { font-size: 16px; scroll-behavior: smooth; } + +body { + background: var(--bg); + color: var(--text); + font-family: var(--font-body); + -webkit-font-smoothing: antialiased; + min-height: 100vh; + overflow-x: hidden; +} + +a { color: inherit; text-decoration: none; } + +/* ── Background Scene ─────────────────────────────────────── */ +.arena-bg { + position: fixed; + inset: 0; + z-index: 0; + overflow: hidden; + pointer-events: none; +} + +/* Perspective grid floor */ +.arena-bg::after { + content: ''; + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 200%; + height: 55%; + background-image: + linear-gradient(to right, rgba(37, 99, 235, 0.18) 1px, transparent 1px), + linear-gradient(to bottom, rgba(37, 99, 235, 0.18) 1px, transparent 1px); + background-size: 80px 80px; + transform-origin: bottom center; + transform: translateX(-50%) perspective(600px) rotateX(60deg); + mask-image: linear-gradient(to top, rgba(0,0,0,0.6) 0%, transparent 80%); + -webkit-mask-image: linear-gradient(to top, rgba(0,0,0,0.6) 0%, transparent 80%); +} + +/* Radial glow center-top */ +.arena-bg::before { + content: ''; + position: absolute; + top: -10%; + left: 50%; + transform: translateX(-50%); + width: 900px; + height: 500px; + background: radial-gradient(ellipse at center, + rgba(37, 99, 235, 0.12) 0%, + rgba(245, 197, 24, 0.05) 40%, + transparent 70%); + border-radius: 50%; +} + +/* Floating geometric shapes */ +.arena-shapes { + position: absolute; + inset: 0; +} + +.shape { + position: absolute; + opacity: 0; + animation: floatShape linear infinite; +} + +.shape--diamond { + width: 20px; + height: 20px; + border: 1.5px solid var(--gold); + transform: rotate(45deg); + opacity: 0; + animation: floatDiamond linear infinite; +} + +.shape--ring { + border-radius: 50%; + border: 1px solid var(--blue-bright); + opacity: 0; + animation: floatRing linear infinite; +} + +.shape--line { + height: 1px; + background: linear-gradient(to right, transparent, var(--gold-mid), transparent); + opacity: 0; + animation: scanLine linear infinite; +} + +@keyframes floatDiamond { + 0% { opacity: 0; transform: rotate(45deg) translateY(0) scale(0.8); } + 10% { opacity: 0.35; } + 90% { opacity: 0.2; } + 100% { opacity: 0; transform: rotate(45deg) translateY(-120px) scale(1.1); } +} + +@keyframes floatRing { + 0% { opacity: 0; transform: scale(0.6); } + 15% { opacity: 0.2; } + 85% { opacity: 0.1; } + 100% { opacity: 0; transform: scale(1.4); } +} + +@keyframes scanLine { + 0% { opacity: 0; transform: translateX(-30px); } + 20% { opacity: 0.5; } + 80% { opacity: 0.3; } + 100% { opacity: 0; transform: translateX(30px); } +} + +/* Noise/grain overlay */ +.arena-grain { + position: absolute; + inset: 0; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E"); + opacity: 0.55; +} + +/* ── Layout helpers ───────────────────────────────────────── */ +.page { + position: relative; + z-index: 1; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* ── Login Page ───────────────────────────────────────────── */ +.login { + align-items: center; + justify-content: center; + padding: 40px 24px 80px; + gap: 0; +} + +/* Top eyebrow */ +.login__eyebrow { + font-family: var(--font-ui); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.28em; + text-transform: uppercase; + color: var(--gold); + margin-bottom: 28px; + display: flex; + align-items: center; + gap: 10px; +} + +.login__eyebrow::before, +.login__eyebrow::after { + content: ''; + display: block; + width: 32px; + height: 1px; + background: var(--gold-mid); +} + +/* Hero title */ +.login__hero { + text-align: center; + margin-bottom: 28px; +} + +.login__title { + font-family: var(--font-display); + font-size: clamp(56px, 12vw, 112px); + line-height: 0.9; + letter-spacing: -0.01em; + text-transform: uppercase; + color: var(--text); + text-shadow: 0 0 80px rgba(245, 197, 24, 0.15); +} + +.login__title span { + display: block; + color: var(--gold); + text-shadow: + 0 0 40px rgba(245, 197, 24, 0.6), + 0 0 80px rgba(245, 197, 24, 0.25); +} + +.login__tagline { + font-family: var(--font-ui); + font-size: clamp(15px, 3vw, 20px); + font-weight: 500; + letter-spacing: 0.06em; + color: var(--text-dim); + text-transform: uppercase; + margin-top: 16px; +} + +.login__tagline em { + font-style: normal; + color: var(--gold); +} + +/* Grade badges */ +.grade-badges { + display: flex; + gap: 12px; + margin: 36px 0; + flex-wrap: wrap; + justify-content: center; +} + +.grade-badge { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + padding: 14px 22px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + position: relative; + overflow: hidden; + transition: transform 0.2s, border-color 0.2s; + cursor: default; + animation: badgePulse 6s ease-in-out infinite; +} + +.grade-badge:nth-child(2) { animation-delay: 2s; } +.grade-badge:nth-child(3) { animation-delay: 4s; } + +.grade-badge::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 2px; +} + +.grade-badge:nth-child(1)::before { background: var(--bronze); box-shadow: 0 0 12px var(--bronze); } +.grade-badge:nth-child(2)::before { background: var(--silver); box-shadow: 0 0 12px var(--silver); } +.grade-badge:nth-child(3)::before { background: var(--platinum); box-shadow: 0 0 12px var(--platinum); } + +.grade-badge:hover { transform: translateY(-3px); } +.grade-badge:nth-child(1):hover { border-color: var(--bronze); } +.grade-badge:nth-child(2):hover { border-color: var(--silver); } +.grade-badge:nth-child(3):hover { border-color: var(--platinum); } + +.grade-badge__rank { + font-family: var(--font-display); + font-size: 26px; + line-height: 1; +} + +.grade-badge:nth-child(1) .grade-badge__rank { color: var(--bronze); } +.grade-badge:nth-child(2) .grade-badge__rank { color: var(--silver); } +.grade-badge:nth-child(3) .grade-badge__rank { color: var(--platinum); } + +.grade-badge__label { + font-family: var(--font-ui); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--text-muted); +} + +@keyframes badgePulse { + 0%, 100% { box-shadow: none; } + 50% { box-shadow: 0 4px 24px rgba(245, 197, 24, 0.07); } +} + +/* Auth error message */ +.login__error { + font-family: var(--font-ui); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.04em; + color: #ea4335; + background: rgba(234, 67, 53, 0.08); + border: 1px solid rgba(234, 67, 53, 0.25); + border-radius: var(--radius-sm); + padding: 10px 16px; + width: 100%; + text-align: center; +} + +/* Sign-in button */ +.login__cta { + margin-top: 8px; + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; +} + +.btn-google { + display: inline-flex; + align-items: center; + gap: 14px; + padding: 16px 36px; + background: var(--text); + color: #111; + font-family: var(--font-ui); + font-size: 17px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + border: none; + border-radius: var(--radius); + cursor: pointer; + transition: background 0.2s, transform 0.15s, box-shadow 0.2s; + text-decoration: none; + box-shadow: 0 0 0 0 rgba(245, 197, 24, 0); +} + +.btn-google:hover { + background: #fff; + transform: translateY(-2px); + box-shadow: 0 8px 32px rgba(245, 197, 24, 0.3); +} + +.btn-google:active { + transform: translateY(0); +} + +.btn-google__icon { + width: 22px; + height: 22px; + flex-shrink: 0; +} + +.login__fine { + font-family: var(--font-ui); + font-size: 12px; + letter-spacing: 0.05em; + color: var(--text-muted); +} + +/* Footer strip */ +.login__footer { + margin-top: auto; + padding-top: 60px; + font-family: var(--font-ui); + font-size: 11px; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--text-muted); + text-align: center; +} + +/* ── Dashboard ────────────────────────────────────────────── */ + +/* Nav bar */ +.dash-nav { + position: fixed; + top: 0; left: 0; right: 0; + height: 64px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 28px; + background: color-mix(in srgb, var(--bg) 88%, transparent); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-bottom: 1px solid var(--border); + z-index: 100; +} + +.dash-nav__logo { + font-family: var(--font-display); + font-size: 18px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text); +} + +.dash-nav__logo span { + color: var(--gold); + margin-left: 2px; +} + +.dash-nav__right { + display: flex; + align-items: center; + gap: 12px; +} + +.dash-nav__avatar { + width: 36px; + height: 36px; + border-radius: 50%; + border: 2px solid var(--gold-mid); + object-fit: cover; + flex-shrink: 0; +} + +.dash-nav__avatar--fallback { + background: var(--gold-dim); + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-display); + font-size: 14px; + color: var(--gold); +} + +.dash-nav__name { + font-family: var(--font-ui); + font-size: 14px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--text); + max-width: 160px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dash-nav__signout { + padding: 6px 14px; + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-family: var(--font-ui); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + cursor: pointer; + transition: color 0.2s, border-color 0.2s; +} + +.dash-nav__signout:hover { + color: var(--text); + border-color: var(--text-muted); +} + +/* ── Profile Dropdown ──────────────────────────────────────── */ +.profile-dropdown { + position: relative; + display: flex; +} + +.profile-dropdown__trigger { + display: flex; + align-items: center; + gap: 12px; + background: none; + border: none; + cursor: pointer; + padding: 0; +} + +.profile-dropdown__menu { + position: absolute; + top: calc(100% + 8px); + right: 0; + min-width: 200px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); + z-index: 200; + overflow: hidden; +} + +.profile-dropdown__user { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; +} + +.profile-dropdown__avatar { + width: 40px; + height: 40px; + border-radius: 50%; + border: 2px solid var(--gold-mid); + object-fit: cover; + flex-shrink: 0; +} + +.profile-dropdown__avatar--fallback { + background: var(--gold-dim); + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-display); + font-size: 16px; + color: var(--gold); +} + +.profile-dropdown__name { + font-family: var(--font-ui); + font-size: 15px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.profile-dropdown__divider { + height: 1px; + background: var(--border); + margin: 0; +} + +.profile-dropdown__item { + width: 100%; + padding: 12px 16px; + background: none; + border: none; + color: var(--text); + font-family: var(--font-ui); + font-size: 14px; + font-weight: 500; + letter-spacing: 0.04em; + cursor: pointer; + text-align: left; + transition: background 0.15s; +} + +.profile-dropdown__item:hover { + background: var(--surface-2); +} + +.profile-dropdown__item--danger { + color: #ef4444; +} + +/* Page wrapper */ +.dash-page { + align-items: center; + padding: 80px 24px 80px; + gap: 0; +} + +/* Hero */ +.dash-hero { + text-align: center; + padding: 48px 0 36px; + width: 100%; + max-width: 740px; +} + +.dash-hero__eyebrow { + font-family: var(--font-ui); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.3em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 12px; +} + +.dash-hero__title { + font-family: var(--font-display); + font-size: clamp(52px, 11vw, 88px); + line-height: 1; + text-transform: uppercase; + color: var(--text); + margin-bottom: 32px; + text-shadow: 0 0 80px rgba(245, 197, 24, 0.08); +} + +.dash-hero__title span { color: var(--gold); } + +/* Grade selector */ +.dash-grade-tabs { + display: inline-flex; + gap: 6px; + padding: 4px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.dash-grade-tab { + padding: 8px 22px; + background: none; + border: 1px solid transparent; + border-radius: calc(var(--radius) - 2px); + color: var(--text-muted); + font-family: var(--font-ui); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.dash-grade-tab:hover { color: var(--text); } + +.dash-grade-tab--active { + background: var(--gold-dim); + border-color: var(--gold-mid); + color: var(--gold); +} + +/* Start Game CTA */ +.dash-cta { + padding: 8px 0 40px; + display: flex; + gap: 16px; +} + +.dash-cta__btn { + display: flex; + align-items: center; + gap: 12px; + padding: 18px 56px; + background: var(--gold); + border: none; + border-radius: var(--radius); + cursor: pointer; + box-shadow: + 0 0 0 1px rgba(245, 197, 24, 0.3), + 0 0 32px rgba(245, 197, 24, 0.25), + 0 4px 16px rgba(0, 0, 0, 0.4); + transition: transform 0.15s, box-shadow 0.2s; +} + +.dash-cta__btn:hover { + transform: translateY(-2px); + box-shadow: + 0 0 0 1px rgba(245, 197, 24, 0.5), + 0 0 48px rgba(245, 197, 24, 0.35), + 0 8px 24px rgba(0, 0, 0, 0.5); +} + +.dash-cta__btn:active { transform: translateY(0); } + +.dash-cta__btn--ranked { + background: transparent; + border: 1px solid var(--blue); + box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.2), 0 0 24px rgba(59, 130, 246, 0.15), 0 4px 16px rgba(0, 0, 0, 0.4); +} +.dash-cta__btn--ranked:hover { + box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.4), 0 0 40px rgba(59, 130, 246, 0.25), 0 8px 24px rgba(0, 0, 0, 0.5); +} +.dash-cta__btn--ranked .dash-cta__label { color: var(--blue-bright); } + +.dash-cta__icon { font-size: 20px; line-height: 1; } + +.dash-cta__label { + font-family: var(--font-display); + font-size: 20px; + color: var(--bg); + text-transform: uppercase; + letter-spacing: 0.04em; + line-height: 1; +} + +/* Stats row */ +.dash-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + width: 100%; + max-width: 740px; + margin-bottom: 40px; +} + +.stat-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 24px 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + position: relative; + overflow: hidden; +} + +.stat-card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 2px; + background: linear-gradient(to right, transparent, var(--border-glow), transparent); +} + +.stat-card__label { + font-family: var(--font-ui); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--text-muted); +} + +.stat-card__value { + font-family: var(--font-display); + font-size: clamp(28px, 5vw, 38px); + line-height: 1; + color: var(--text); +} + +.stat-card__value--accent { + color: var(--gold); + text-shadow: 0 0 20px rgba(245, 197, 24, 0.3); +} + +/* Content sections */ +.dash-section { + width: 100%; + max-width: 740px; + margin-bottom: 32px; +} + +.dash-section__hd { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + padding: 0 2px; +} + +.dash-section__title { + font-family: var(--font-ui); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--text-muted); +} + +.dash-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; +} + +/* Subject tabs */ +.dash-tabs { display: flex; gap: 4px; } + +.dash-tab { + padding: 5px 14px; + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-family: var(--font-ui); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + cursor: pointer; + transition: all 0.15s; +} + +.dash-tab:hover { color: var(--text); border-color: var(--text-muted); } + +.dash-tab--active { + background: var(--gold-dim); + border-color: var(--gold-mid); + color: var(--gold); +} + +/* Leaderboard */ +/* Section badge (e.g. "Best Time" label) */ +.dash-section__badge { + font-family: var(--font-ui); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--gold); + background: var(--gold-dim); + border: 1px solid var(--gold-mid); + border-radius: 4px; + padding: 3px 9px; +} + +.lb-table { width: 100%; } + +.lb-header { + display: flex; + align-items: center; + gap: 14px; + padding: 8px 20px; + border-bottom: 1px solid var(--border); +} + +.lb-header__rank { + font-family: var(--font-ui); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-muted); + min-width: 28px; + text-align: center; + flex-shrink: 0; +} + +.lb-header__player { + font-family: var(--font-ui); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-muted); + flex: 1; +} + +.lb-header__time { + font-family: var(--font-ui); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-muted); + flex-shrink: 0; +} + +.lb-row { + display: flex; + align-items: center; + gap: 14px; + padding: 11px 20px; + border-bottom: 1px solid var(--border); + transition: background 0.15s; +} + +.lb-row:last-child { border-bottom: none; } +.lb-row:hover { background: rgba(255, 255, 255, 0.02); } + +.lb-row--current { + background: var(--gold-dim); + border-left: 2px solid var(--gold); + padding-left: 18px; +} + +.lb-row--current:hover { background: rgba(245, 197, 24, 0.1); } + +.lb-rank { + font-size: 20px; + line-height: 1; + min-width: 28px; + text-align: center; + flex-shrink: 0; +} + +.lb-rank--num { + font-family: var(--font-display); + font-size: 14px; + color: var(--text-muted); +} + +.lb-user { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; +} + +.lb-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.lb-avatar--fallback { + background: var(--surface-2); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-display); + font-size: 11px; + color: var(--text-dim); +} + +.lb-name { + font-family: var(--font-ui); + font-size: 15px; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.lb-you { + padding: 2px 7px; + background: var(--gold-dim); + border: 1px solid var(--gold-mid); + border-radius: 3px; + font-family: var(--font-ui); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.12em; + color: var(--gold); + flex-shrink: 0; +} + +.lb-score { + font-family: var(--font-display); + font-size: 18px; + color: var(--text); + flex-shrink: 0; +} + +.lb-row--current .lb-score { color: var(--gold); } + +.lb-score--time { + font-family: var(--font-ui); + font-size: 16px; + font-weight: 700; + letter-spacing: 0.06em; + color: var(--text); +} + +.lb-skeleton { + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.lb-skel-row { + height: 44px; + border-radius: var(--radius); + background: var(--surface-2); + animation: shimmer 1.6s ease-in-out infinite; +} + +/* Empty state */ +.dash-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 48px 24px; +} + +.dash-empty__icon { + font-size: 28px; + opacity: 0.5; + margin-bottom: 8px; +} + +.dash-empty__text { + font-family: var(--font-ui); + font-size: 15px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--text-dim); +} + +.dash-empty__sub { + font-family: var(--font-ui); + font-size: 12px; + color: var(--text-muted); +} + +/* Error banner */ +.dash-error-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + max-width: 740px; + margin-bottom: 20px; + padding: 12px 16px; + background: rgba(234, 67, 53, 0.08); + border: 1px solid rgba(234, 67, 53, 0.25); + border-radius: var(--radius); + font-family: var(--font-ui); + font-size: 13px; + font-weight: 500; + color: #f28b82; + letter-spacing: 0.02em; +} + +.dash-error-banner__dismiss { + background: none; + border: none; + color: #f28b82; + cursor: pointer; + font-size: 14px; + padding: 0 2px; + opacity: 0.7; + flex-shrink: 0; + transition: opacity 0.15s; +} + +.dash-error-banner__dismiss:hover { opacity: 1; } + +/* Loading skeleton */ +.skel-pill { + background: var(--surface-2); + border-radius: var(--radius); + animation: shimmer 1.6s ease-in-out infinite; +} + +@keyframes shimmer { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 0.85; } +} + +/* ── Trophy Button ─────────────────────────────────────────── */ +.lb-trophy-btn { + position: fixed; + bottom: 24px; + right: 24px; + width: 48px; + height: 48px; + border-radius: 50%; + background: var(--surface); + border: 1px solid var(--gold-mid); + color: var(--gold); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + z-index: 50; + transition: transform 0.2s, box-shadow 0.2s, background 0.2s; + box-shadow: 0 0 20px rgba(245, 197, 24, 0.15); +} + +.lb-trophy-btn:hover { + transform: translateY(-2px) scale(1.05); + background: var(--gold-dim); + box-shadow: 0 0 32px rgba(245, 197, 24, 0.3); +} + +.lb-trophy-btn:active { + transform: translateY(0) scale(0.95); +} + +.lb-trophy-btn svg { + width: 24px; + height: 24px; +} + +/* ── Leaderboard Modal ─────────────────────────────────────── */ +.lb-modal-overlay { + position: fixed; + inset: 0; + z-index: 200; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + animation: lbFadeIn 0.2s ease-out; +} + +.lb-modal { + width: 100%; + max-width: 480px; + max-height: 85vh; + display: flex; + flex-direction: column; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + animation: lbSlideUp 0.25s ease-out; +} + +.lb-modal__hd { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px 12px; + border-bottom: 1px solid var(--border); +} + +.lb-modal__title-row { + display: flex; + align-items: center; + gap: 10px; +} + +.lb-modal__icon { + color: var(--gold); + width: 24px; + height: 24px; + display: flex; + align-items: center; +} + +.lb-modal__icon svg { + width: 24px; + height: 24px; +} + +.lb-modal__title { + font-family: var(--font-display); + font-size: 20px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text); +} + +.lb-modal__close { + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 14px; + transition: color 0.15s, border-color 0.15s; +} + +.lb-modal__close:hover { + color: var(--text); + border-color: var(--text-muted); +} + +.lb-modal__grade-tabs { + display: flex; + gap: 6px; + padding: 16px 24px 12px; +} + +.lb-modal__grade-tab { + padding: 6px 18px; + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-family: var(--font-ui); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + cursor: pointer; + transition: all 0.15s; +} + +.lb-modal__grade-tab:hover { color: var(--text); border-color: var(--text-muted); } + +.lb-modal__grade-tab--active { + background: var(--gold-dim); + border-color: var(--gold-mid); + color: var(--gold); +} + +.lb-modal__body { + flex: 1; + overflow-y: auto; + padding: 0 0 8px; +} + +@keyframes lbFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes lbSlideUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── Settings Page ─────────────────────────────────────────── */ +.settings-page { + align-items: center; + padding: 80px 24px 80px; + gap: 0; +} + +.settings-section { + width: 100%; + max-width: 520px; + margin-top: 48px; +} + +.settings-title { + font-family: var(--font-display); + font-size: 28px; + letter-spacing: 0.03em; + color: var(--text); + margin-bottom: 24px; +} + +.settings-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.settings-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; +} + +.settings-row__info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.settings-row__label { + font-family: var(--font-ui); + font-size: 16px; + font-weight: 600; + letter-spacing: 0.03em; + color: var(--text); +} + +.settings-row__desc { + font-family: var(--font-body); + font-size: 13px; + color: var(--text-dim); +} + +.settings-toggle { + position: relative; + width: 48px; + height: 26px; + border-radius: 13px; + background: var(--surface-2); + border: 1px solid var(--border); + cursor: pointer; + transition: background 0.2s, border-color 0.2s; + flex-shrink: 0; +} + +.settings-toggle--on { + background: var(--gold); + border-color: var(--gold-mid); +} + +.settings-toggle__thumb { + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--text); + transition: transform 0.2s; +} + +.settings-toggle--on .settings-toggle__thumb { + transform: translateX(22px); + background: #1a1a2e; +} + +.settings-back { + padding: 6px 14px; + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-family: var(--font-ui); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + cursor: pointer; + transition: color 0.2s, border-color 0.2s; +} + +.settings-back:hover { + color: var(--text); + border-color: var(--text-muted); +} + +.settings-back-link { + margin-top: 32px; + background: none; + border: none; + color: var(--text-dim); + font-family: var(--font-ui); + font-size: 14px; + font-weight: 500; + letter-spacing: 0.04em; + cursor: pointer; + transition: color 0.2s; +} + +.settings-back-link:hover { + color: var(--text); +} + +/* ── Username nav elements ────────────────────────────────── */ +.dash-nav__username { + background: none; + border: none; + font-family: var(--font-ui); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.05em; + color: var(--text-dim); + cursor: pointer; + padding: 4px 8px; + border-radius: var(--radius-sm); + transition: color 0.15s; +} + +.dash-nav__username:hover { color: var(--text); } + +.dash-nav__set-username { + background: none; + border: none; + font-family: var(--font-ui); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.06em; + color: var(--gold); + cursor: pointer; + padding: 4px 8px; + border-radius: var(--radius-sm); + opacity: 0.75; + transition: opacity 0.15s; +} + +.dash-nav__set-username:hover { opacity: 1; } + +/* ── Username modal ───────────────────────────────────────── */ +.uname-overlay { + position: fixed; + inset: 0; + z-index: 300; + background: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(6px); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + animation: lbFadeIn 0.18s ease-out; +} + +.uname-modal { + width: 100%; + max-width: 380px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 28px 28px 24px; + display: flex; + flex-direction: column; + gap: 12px; + animation: lbSlideUp 0.2s ease-out; +} + +.uname-modal__title { + font-family: var(--font-display); + font-size: 20px; + letter-spacing: 0.03em; + text-transform: uppercase; + color: var(--text); +} + +.uname-modal__hint { + font-family: var(--font-ui); + font-size: 12px; + letter-spacing: 0.04em; + color: var(--text-muted); + margin-top: -4px; +} + +.uname-input { + width: 100%; + padding: 11px 14px; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-family: var(--font-ui); + font-size: 15px; + font-weight: 500; + letter-spacing: 0.04em; + outline: none; + transition: border-color 0.15s; +} + +.uname-input:focus { border-color: var(--gold-mid); } + +.uname-input::placeholder { color: var(--text-muted); } + +.uname-error { + font-family: var(--font-ui); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.03em; + color: #f28b82; + background: rgba(234, 67, 53, 0.08); + border: 1px solid rgba(234, 67, 53, 0.2); + border-radius: var(--radius-sm); + padding: 8px 12px; +} + +.uname-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} + +.uname-btn { + padding: 9px 22px; + border-radius: var(--radius); + font-family: var(--font-ui); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; + transition: opacity 0.15s, transform 0.12s; + border: none; +} + +.uname-btn:disabled { opacity: 0.5; cursor: not-allowed; } + +.uname-btn--cancel { + background: var(--surface-2); + color: var(--text-muted); + border: 1px solid var(--border); +} + +.uname-btn--cancel:hover:not(:disabled) { color: var(--text); } + +.uname-btn--save { + background: var(--gold); + color: #07080f; +} + +.uname-btn--save:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); } + +/* ── Utility ──────────────────────────────────────────────── */ +.sr-only { + position: absolute; + width: 1px; height: 1px; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; +} + +/* ── Responsive ───────────────────────────────────────────── */ +@media (max-width: 600px) { + .dash-nav { padding: 0 16px; } + .profile-dropdown__trigger .dash-nav__name { display: none; } + .dash-cta__btn { padding: 16px 40px; } + .dash-stats { gap: 8px; } + .stat-card { padding: 18px 10px; } + .stat-card__value { font-size: clamp(22px, 6vw, 32px); } + .lb-row { padding: 10px 14px; gap: 10px; } + .lb-row--current { padding-left: 12px; } +} + +@media (max-width: 480px) { + .grade-badges { gap: 8px; } + .grade-badge { padding: 12px 16px; } + .dash-grade-tab { padding: 8px 14px; font-size: 12px; } + .dash-hero__title { font-size: clamp(44px, 14vw, 88px); } +} \ No newline at end of file diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..d9cc93c --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import './index.css'; +import App from './App.jsx'; + +createRoot(document.getElementById('root')).render( + + + +); diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx new file mode 100644 index 0000000..ae1d085 --- /dev/null +++ b/frontend/src/pages/Dashboard.jsx @@ -0,0 +1,405 @@ +import { useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import ProfileDropdown from '../components/ProfileDropdown'; + +const API = import.meta.env.VITE_API_URL; + +const GRADES = [ + { id: 'G3', label: 'Grade 3' }, + { id: 'G6', label: 'Grade 6' }, + { id: 'G9', label: 'Grade 9' }, +]; + +function decodeJwt(token) { + try { + return JSON.parse(atob(token.split('.')[1])); + } catch { + return null; + } +} + +function fmtTime(ms) { + if (ms == null) return '—'; + const s = Math.floor(ms / 1000); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; +} + +function StatCard({ label, value, accent = false }) { + return ( +
+ {label} + + {value} + +
+ ); +} + +const RANK_STYLES = [ + { color: '#f5c518', textShadow: '0 0 12px rgba(245,197,24,0.6)' }, + { color: '#94a3b8', textShadow: '0 0 10px rgba(148,163,184,0.5)' }, + { color: '#c9824a', textShadow: '0 0 10px rgba(201,130,74,0.5)' }, +]; + +function LeaderboardTable({ entries, loading, highlightRank, grade }) { + if (loading) { + return ( +
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+ ); + } + + if (!entries.length) { + const gradeLabel = GRADES.find(g => g.id === grade)?.label ?? grade; + return ( +
+ +

No times recorded yet for {gradeLabel}

+

Complete a quiz to appear on the board

+
+ ); + } + + return ( +
+
+ Rank + Player + Best Time +
+ {entries.map(entry => { + const isCurrent = highlightRank !== null && entry.rank === highlightRank; + const rankStyle = entry.rank <= 3 ? RANK_STYLES[entry.rank - 1] : null; + const playerLabel = entry.username ? `@${entry.username}` : entry.displayName; + const fallbackLetter = (entry.username ?? entry.displayName)?.[0]?.toUpperCase() ?? '?'; + return ( +
+ 3 ? ' lb-rank--num' : ''}`} + style={rankStyle ?? undefined} + > + {entry.rank} + +
+ {entry.avatarUrl + ? {playerLabel} + :
{fallbackLetter}
+ } + {playerLabel} + {isCurrent && YOU} +
+ {fmtTime(entry.bestTime)} +
+ ); + })} +
+ ); +} + +function UsernameModal({ token, current, onClose, onSave }) { + const [value, setValue] = useState(current ?? ''); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + const onKey = (e) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + async function handleSave() { + const trimmed = value.trim(); + if (!trimmed) return; + setSaving(true); + setError(null); + try { + const res = await fetch(`${API}/me/username`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ username: trimmed }), + }); + const data = await res.json(); + if (res.ok) { onSave(data.username); onClose(); } + else if (data.error === 'taken') setError('Username already taken — try another.'); + else setError('3–20 characters, letters, numbers, and underscores only.'); + } catch { + setError('Could not save. Check your connection.'); + } finally { + setSaving(false); + } + } + + return ( +
+
e.stopPropagation()}> +

Set username

+

3–20 chars · letters, numbers, underscores

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

{error}

} +
+ + +
+
+
+ ); +} + +function LoadingSkeleton() { + return ( + <> +
+ +
+
+
+
+
+ {[0, 1, 2].map(i => ( +
+ ))} +
+
+
+
+
+
+ {[0, 1, 2].map(i => ( +
+ ))} +
+
+ + ); +} + +export default function Dashboard() { + const navigate = useNavigate(); + const [token, setToken] = useState(null); + const [jwtPayload, setJwtPayload] = useState(null); + const [user, setUser] = useState(null); + const [username, setUsername] = useState(null); + const [usernameModal, setUsernameModal] = useState(false); + const [stats, setStats] = useState([]); + const [leaderboard, setLeaderboard] = useState([]); + const [selectedGrade, setSelectedGrade] = useState('G3'); + const [loading, setLoading] = useState(true); + const [lbLoading, setLbLoading] = useState(false); + const [error, setError] = useState(null); + const profileElo = stats.find(e => e.grade === selectedGrade)?.elo ?? null; + + // ── Auth init ── + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const urlToken = params.get('token'); + let tok = urlToken; + if (urlToken) { + sessionStorage.setItem('ranked_token', urlToken); + history.replaceState(null, '', window.location.pathname); + } else { + tok = sessionStorage.getItem('ranked_token'); + } + if (!tok) { navigate('/'); return; } + setToken(tok); + setJwtPayload(decodeJwt(tok)); + }, [navigate]); + + // ── Fetch user + stats ── + useEffect(() => { + if (!token) return; + const headers = { Authorization: `Bearer ${token}` }; + Promise.all([ + fetch(`${API}/auth/me`, { headers }), + fetch(`${API}/me/stats`, { headers }), + ]) + .then(async ([meRes, statsRes]) => { + if (meRes.status === 401) { + sessionStorage.removeItem('ranked_token'); + setLoading(false); + navigate('/'); + return; + } + const [meData, statsData] = await Promise.all([meRes.json(), statsRes.json()]); + setUser(meData); + setUsername(meData.username ?? null); + setStats(statsData.entries ?? []); + setLoading(false); + }) + .catch(() => { + setError('Could not reach the server. Check your connection and refresh.'); + setLoading(false); + }); + }, [token, navigate]); + + // ── Fetch leaderboard ── + useEffect(() => { + if (!token) return; + setLbLoading(true); + fetch(`${API}/leaderboard?grade=${selectedGrade}`) + .then(res => res.json()) + .then(data => setLeaderboard(Array.isArray(data) ? data : [])) + .catch(() => setLeaderboard([])) + .finally(() => setLbLoading(false)); + }, [token, selectedGrade]); + + const signOut = () => { + sessionStorage.removeItem('ranked_token'); + navigate('/'); + }; + + const currentEntry = stats.find(e => e.grade === selectedGrade); + const highlightRank = currentEntry?.timeRank ?? null; + + const displayName = user?.displayName ?? jwtPayload?.name ?? 'Player'; + const firstName = displayName.split(' ')[0]; + const avatarUrl = user?.avatarUrl ?? jwtPayload?.picture ?? null; + const initial = displayName[0]?.toUpperCase() ?? '?'; + + if (loading) return ; + + return ( + <> +
+ + {usernameModal && ( + setUsernameModal(false)} + onSave={(u) => setUsername(u)} + /> + )} + + {/* ── Nav ── */} + + +
+ + {/* ── Hero ── */} +
+

{username ?? firstName}

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

Leaderboard

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

Recent Activity

+
+
+
+ 🎮 +

No games played yet

+

Complete a game to see your activity here

+
+
+
+ +
+ + ); +} diff --git a/frontend/src/pages/Game.jsx b/frontend/src/pages/Game.jsx new file mode 100644 index 0000000..f2cfd36 --- /dev/null +++ b/frontend/src/pages/Game.jsx @@ -0,0 +1,468 @@ +import { useState, useEffect, useRef } from 'react'; +import treeBg from '../assets/tree_bg_svg.svg'; +import { useNavigate, useLocation } from 'react-router-dom'; + +const API = import.meta.env.VITE_API_URL; +const STAGE_SIZE = 11; + +function fmtDuration(ms) { + const s = Math.floor(ms / 1000); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; +} + +// ── ProgressStrip ────────────────────────────────────────────── +function ProgressStrip({ total, current, answers, onJump }) { + const items = []; + for (let qi = 0; qi < total; qi++) { + const active = qi === current; + const answered = answers[qi] != null; + const filled = answered; + items.push( + + ); + if (qi < total - 1) { + items.push( +
+ ); + } + } + return ( +
+ {items} +
+ ); +} + +const BTN = { + padding: '8px 22px', borderRadius: 999, fontFamily: 'system-ui,sans-serif', + fontSize: 14, fontWeight: 600, cursor: 'pointer', + border: '1px solid #b6d8f5', background: '#e8f4fd', color: '#1a73e8', +}; + +const NAV_BTN = { + padding: '14px 40px', borderRadius: 999, fontFamily: 'system-ui,sans-serif', + fontSize: 17, fontWeight: 600, cursor: 'pointer', + border: '1.5px solid #b6d8f5', background: '#e8f4fd', color: '#1a73e8', +}; + +const WRAP = { display: 'flex', flexDirection: 'column', minHeight: '100vh', background: '#fff', fontFamily: 'system-ui,sans-serif' }; + +// ── Screens ──────────────────────────────────────────────────── + +function StageIntro({ stage, onNext }) { + return ( +
+
+ +
+
+
+

Mathematics — Stage {stage}

+

+ You are ready to begin Stage {stage} in Mathematics. +

+

Answer the next {STAGE_SIZE} questions to complete this stage.

+
+
+ +
+
+
+ ); +} + +function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext, onBack, onJump }) { + useEffect(() => { + const handler = (e) => { + const i = +e.key - 1; + if (i >= 0 && i < q.choices.length) { onSelect(i); return; } + if ((e.key === 'Enter' || e.key === 'ArrowRight') && qIdx < STAGE_SIZE - 1) onNext(); + if (e.key === 'ArrowLeft' && qIdx > 0) onBack(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [q.choices.length, qIdx, onSelect, onNext, onBack]); + + return ( +
+
+
+

+ Question {qIdx + 1} of {STAGE_SIZE} +

+ +
+ +
+

+ {q.question} +

+
+ {q.choices.map((choice, i) => { + const sel = selected === i; + return ( + + ); + })} +
+
+ +
+ {qIdx > 0 && } + {qIdx === STAGE_SIZE - 1 ? ( + + ) : ( + + )} +
+
+
+ ); +} + +function StageResult({ stage, wrongCount, questions, answers: initialAnswers, onRetry, onNext }) { + const [viewIdx, setViewIdx] = useState(0); + const [localAnswers, setLocalAnswers] = useState(initialAnswers); + + const q = questions[viewIdx]; + const storedAnswer = localAnswers[viewIdx] ?? null; + + return ( +
+
+

Mathematics

+
+
+ + {wrongCount > 0 && ( +
+

+ {wrongCount} question{wrongCount > 1 ? 's' : ''} were incorrect. +

+
+ )} + +
+
+

{q.question}

+
+ {q.choices.map((choice, i) => { + const sel = storedAnswer === i; + return ( + + ); + })} +
+
+ Your answer: {storedAnswer !== null ? q.choices[storedAnswer] : '—'} +
+
+ +
+

Stage {stage}

+
+ {questions.map((_, qi) => { + const active = qi === viewIdx; + return ( + + ); + })} +
+
+
+ +
+ {wrongCount === 0 + ? + : + } +
+
+ ); +} + +function CompleteScreen({ duration, grade, onDashboard }) { + const [pbState, setPbState] = useState(null); + + useEffect(() => { + const token = sessionStorage.getItem('ranked_token'); + if (!token || !grade) { setPbState({ updated: false }); return; } + fetch(`${API}/me/best-time`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ grade, timeMs: duration }), + }) + .then(r => r.json()) + .then(data => setPbState({ updated: data.updated ?? false })) + .catch(() => setPbState({ updated: false })); + }, []); + + return ( +
+
+
🎉
+

Quiz Complete!

+

You completed both stages of the Mathematics quiz.

+
+ ⏱ {fmtDuration(duration)} +
+ {pbState?.updated && ( +
+ ★ New Personal Best! +
+ )} + +
+
+ ); +} + +// ── Game Controller ──────────────────────────────────────────── + +export default function Game() { + const navigate = useNavigate(); + const { state } = useLocation(); + const grade = state?.grade ?? null; + + const [phase, setPhase] = useState('loading'); + const [stage, setStage] = useState(1); + const [questions, setQuestions] = useState([]); + const [qIdx, setQIdx] = useState(0); + const [answers, setAnswers] = useState({}); + const [selected, setSelected] = useState(null); + const [wrongCount, setWrongCount] = useState(0); + const [duration, setDuration] = useState(0); + const [elapsed, setElapsed] = useState(0); + const [fetchError, setFetchError] = useState(false); + + const timerStartRef = useRef(null); + const intervalRef = useRef(null); + const stagedQsRef = useRef({ 1: [], 2: [] }); + const sessionIdRef = useRef(null); + const s1CorrectRef = useRef(0); + + // Fetch both stages in parallel while the user reads the intro screen + useEffect(() => { + if (!grade) { navigate('/dashboard', { replace: true }); return; } + const token = sessionStorage.getItem('ranked_token'); + const headers = { Authorization: `Bearer ${token}` }; + Promise.all([ + fetch(`${API}/questions?grade=${grade}`, { headers }).then(r => r.json()), + fetch(`${API}/questions?grade=${grade}`, { headers }).then(r => r.json()), + ]).then(([s1, s2]) => { + stagedQsRef.current = { 1: s1, 2: s2 }; + setQuestions(s1); + setPhase('intro'); + }).catch(() => setFetchError(true)); + }, [grade, navigate]); + + useEffect(() => () => clearInterval(intervalRef.current), []); + + function startTimer() { + timerStartRef.current = Date.now(); + intervalRef.current = setInterval(() => setElapsed(Date.now() - timerStartRef.current), 500); + } + + function stopTimer() { + clearInterval(intervalRef.current); + intervalRef.current = null; + return timerStartRef.current ? Date.now() - timerStartRef.current : 0; + } + + if (!grade) return null; + + if (fetchError) return ( +
+

Failed to load questions.

+ +
+ ); + + function loadStage(s) { + setQuestions(stagedQsRef.current[s]); + setStage(s); + setQIdx(0); + setAnswers({}); + setSelected(null); + setPhase('intro'); + } + + function startSession() { + const token = sessionStorage.getItem('ranked_token'); + fetch(`${API}/session/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ grade, subject: 'MATH' }), + }).then(r => r.json()).then(d => { sessionIdRef.current = d.sessionId ?? null; }).catch(() => {}); + } + + function endSession(totalCorrect) { + if (!sessionIdRef.current) return; + const token = sessionStorage.getItem('ranked_token'); + fetch(`${API}/session/complete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ sessionId: sessionIdRef.current, correctAnswers: totalCorrect, totalQuestions: STAGE_SIZE * 2 }), + }).catch(() => {}); + } + + function handleNext() { + const newAnswers = { ...answers, [qIdx]: selected }; + setAnswers(newAnswers); + if (qIdx < STAGE_SIZE - 1) { + const next = qIdx + 1; + setQIdx(next); + setSelected(newAnswers[next] ?? null); + } else { + const wrong = questions.filter((q, i) => newAnswers[i] !== q.correct).length; + setWrongCount(wrong); + setPhase('result'); + } + } + + function handleBack() { + const newAnswers = selected !== null ? { ...answers, [qIdx]: selected } : answers; + setAnswers(newAnswers); + const prev = qIdx - 1; + setQIdx(prev); + setSelected(newAnswers[prev] ?? null); + } + + function handleJump(targetIdx) { + const newAnswers = selected !== null ? { ...answers, [qIdx]: selected } : answers; + setAnswers(newAnswers); + setQIdx(targetIdx); + setSelected(newAnswers[targetIdx] ?? null); + } + + function handleRetry() { + setQIdx(0); + setSelected(answers[0] ?? null); + setPhase('question'); + } + + function handleResultNext() { + if (stage === 1) { + s1CorrectRef.current = STAGE_SIZE - wrongCount; + loadStage(2); + } else { + endSession(s1CorrectRef.current + (STAGE_SIZE - wrongCount)); + setDuration(stopTimer()); + setPhase('complete'); + } + } + + const timerRunning = timerStartRef.current !== null && phase !== 'complete'; + const timerSecs = Math.floor(elapsed / 1000); + const timerDisplay = `${String(Math.floor(timerSecs / 60)).padStart(2, '0')}:${String(timerSecs % 60).padStart(2, '0')}`; + + function renderPhase() { + if (phase === 'loading') return ( +
+
+ +
+ ); + if (phase === 'intro') return { if (stage === 1) { startTimer(); startSession(); } setPhase('question'); }} />; + if (phase === 'question') return ; + if (phase === 'result') return ; + if (phase === 'complete') return navigate('/dashboard')} />; + return null; + } + + return ( + <> + {timerRunning && ( +
+ ⏱ {timerDisplay} +
+ )} + {renderPhase()} + + ); +} diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx new file mode 100644 index 0000000..4f7e252 --- /dev/null +++ b/frontend/src/pages/Login.jsx @@ -0,0 +1,162 @@ +import { useState } from 'react'; +import LeaderboardModal from '../components/LeaderboardModal'; + +const API = import.meta.env.VITE_API_URL; + +/* Animated background shapes — generated once, stable positions */ +const DIAMONDS = [ + { left: '8%', top: '15%', size: 14, dur: '9s', delay: '0s' }, + { left: '22%', top: '60%', size: 10, dur: '13s', delay: '2s' }, + { left: '55%', top: '25%', size: 18, dur: '11s', delay: '0.5s' }, + { left: '72%', top: '70%', size: 12, dur: '8s', delay: '3s' }, + { left: '88%', top: '35%', size: 16, dur: '14s', delay: '1s' }, + { left: '38%', top: '80%', size: 8, dur: '10s', delay: '4s' }, + { left: '64%', top: '12%', size: 11, dur: '12s', delay: '1.5s' }, +]; + +const RINGS = [ + { left: '15%', top: '40%', size: 60, dur: '7s', delay: '0s' }, + { left: '80%', top: '20%', size: 44, dur: '9s', delay: '2.5s' }, + { left: '50%', top: '65%', size: 80, dur: '11s', delay: '1s' }, +]; + +const SCANLINES = [ + { top: '30%', width: '160px', left: '5%', dur: '8s', delay: '0s' }, + { top: '55%', width: '120px', left: '75%', dur: '10s', delay: '3s' }, + { top: '20%', width: '200px', left: '40%', dur: '12s', delay: '1.5s' }, +]; + +export default function Login() { + const [lbOpen, setLbOpen] = useState(false); + const authError = new URLSearchParams(window.location.search).get('error') === 'auth_failed'; + return ( + <> + setLbOpen(false)} /> + + {/* Animated background */} +
+
+
+ {DIAMONDS.map((d, i) => ( +
+ ))} + {RINGS.map((r, i) => ( +
+ ))} + {SCANLINES.map((s, i) => ( +
+ ))} +
+
+ + {/* Page content */} +
+

Ontario Standardized Tests

+ +
+

+ Ranked + EQAO +

+

+ Compete. Practice. Dominate your grade. +

+
+ +
+ {[ + { id: 'G3', label: 'Grade 3' }, + { id: 'G6', label: 'Grade 6' }, + { id: 'G9', label: 'Grade 9' }, + ].map(({ id, label }) => ( +
+ {id} + {label} +
+ ))} +
+ +
+ {authError && ( +

Sign-in failed — please try again.

+ )} + + + Sign in with Google + +

Free · No credit card required

+
+ +
+ Ranked EQAO · Built for Ontario Students +
+
+ + + + ); +} + +function GoogleIcon() { + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/pages/Queue.jsx b/frontend/src/pages/Queue.jsx new file mode 100644 index 0000000..76ee863 --- /dev/null +++ b/frontend/src/pages/Queue.jsx @@ -0,0 +1,505 @@ +import { useState, useEffect, useRef } from 'react'; +import { useNavigate, useLocation, Navigate } from 'react-router-dom'; + +const VALID_GRADES = new Set(['G3', 'G6', 'G9']); + +const STAGE_SIZE = 11; + +const API = import.meta.env.VITE_API_URL; +const NAV_BTN = { + padding: '14px 40px', borderRadius: 999, + fontFamily: 'system-ui,sans-serif', fontSize: 17, fontWeight: 600, + cursor: 'pointer', border: '1.5px solid #b6d8f5', + background: '#e8f4fd', color: '#1a73e8', +}; + +function OpponentBar({ progress, total, name }) { + const pct = (progress / total) * 100; + return ( +
+ {name} +
+
+
+ {Math.min(Math.round(progress), total)}/{total} +
+ ); +} + +function FindingScreen({ elapsed, onBack, onForceMatch, showForceMatch }) { + const [dots, setDots] = useState(''); + useEffect(() => { const t = setInterval(() => setDots(d => d.length >= 3 ? '' : d + '.'), 400); return () => clearInterval(t); }, []); + const minutes = Math.floor(elapsed / 60); + const seconds = elapsed % 60; + return ( +
+
+

Searching for opponent{dots}

+

Searching all players...

+
+ Time elapsed: {minutes}:{String(seconds).padStart(2, '0')} +
+ + {showForceMatch && ( + + )} + +
+ ); +} + +function PlayerChip({ name, avatar, reverse }) { + const pic = avatar + ? + :
{name?.[0]?.toUpperCase() ?? '?'}
; + const label = {name}; + return ( +
+ {pic}{label} +
+ ); +} + +function StageIntro({ grade, myName, myAvatar, opponentName, opponentAvatar, onNext }) { + const [count, setCount] = useState(5); + const gradeNum = grade?.replace('G', '') ?? ''; + + useEffect(() => { + const id = setInterval(() => { + setCount(c => { + if (c <= 1) { clearInterval(id); onNext(); return 0; } + return c - 1; + }); + }, 1000); + return () => clearInterval(id); + }, [onNext]); + + return ( +
+

Ranked Match

+

EQAO Grade {gradeNum}

+
+ + vs + +
+

First to finish all {STAGE_SIZE} questions wins.

+
{count}
+
+ ); +} + +function ProgressStrip({ total, current, answers, onJump }) { + const items = []; + for (let qi = 0; qi < total; qi++) { + const active = qi === current, answered = answers[qi] != null, filled = answered; + items.push( + + ); + if (qi < total - 1) items.push(
); + } + return
{items}
; +} + +function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext, onBack, onJump, opponentProgress, opponentName }) { + useEffect(() => { + const handler = (e) => { + const i = +e.key - 1; + if (i >= 0 && i < q.choices.length) { onSelect(i); return; } + if ((e.key === 'Enter' || e.key === 'ArrowRight') && qIdx < STAGE_SIZE - 1) onNext(); + if (e.key === 'ArrowLeft' && qIdx > 0) onBack(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [q.choices.length, qIdx, onSelect, onNext, onBack]); + + return ( +
+
+
+

Question {qIdx + 1} of {STAGE_SIZE}

+
+ You +
+
+ +
+
+

{q.question}

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

Defeated!

+

{opponentName} finished before you.

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

Waiting for {opponentName}...

+

You finished! Now waiting for your opponent to complete.

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

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

+

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

+
+ ⏱ {Math.floor(duration / 1000 / 60)}:{String(Math.floor(duration / 1000) % 60).padStart(2, '0')} +
+ {eloChange !== 0 &&
0 ? '#1b8a2d' : '#c62828' }}>ELO {eloChange > 0 ? '+' : ''}{eloChange}
} + +
+
+ ); +} + +function authHeaders() { + const token = sessionStorage.getItem('ranked_token'); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +export default function Queue() { + const navigate = useNavigate(); + const { state } = useLocation(); + const grade = state?.grade ?? null; + + const [myName, setMyName] = useState(''); + const [myAvatar, setMyAvatar] = useState(null); + + const [phase, setPhase] = useState('finding'); + const [questions, setQuestions] = useState([]); + const [qIdx, setQIdx] = useState(0); + const [answers, setAnswers] = useState({}); + const [selected, setSelected] = useState(null); + const [opponentProgress, setOpponentProgress] = useState(0); + const [opponentName, setOpponentName] = useState(''); + const [opponentAvatar, setOpponentAvatar] = useState(null); + const [playerWon, setPlayerWon] = useState(null); + const [showDefeatOverlay, setShowDefeatOverlay] = useState(false); + const [startTime] = useState(() => Date.now()); + const [duration, setDuration] = useState(0); + const [eloChange, setEloChange] = useState(0); + const [queueElapsed, setQueueElapsed] = useState(0); + const [showForceMatch, setShowForceMatch] = useState(false); + + const gameIdRef = useRef(null); + const pollRef = useRef(null); + const completedRef = useRef(false); + const submittedRef = useRef(false); + const joinedRef = useRef(false); + const leaveTimerRef = useRef(null); + + // Elapsed timer while finding + useEffect(() => { + if (phase !== 'finding') return; + const id = setInterval(() => setQueueElapsed(e => e + 1), 1000); + return () => clearInterval(id); + }, [phase]); + + // Show force match after 15s + useEffect(() => { + if (phase !== 'finding') return; + const id = setTimeout(() => setShowForceMatch(true), 15000); + return () => clearTimeout(id); + }, [phase]); + + async function forceMatch() { + clearInterval(pollRef.current); + console.log('[queue] force match requested'); + try { + const r = await fetch(`${API}/queue/debug`, { headers: authHeaders() }); + const debug = await r.json(); + console.log('[queue] debug state:', JSON.stringify(debug)); + } catch {} + fetch(`${API}/queue/join`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ grade }), + }) + .then(r => { + if (r.status === 401) { sessionStorage.removeItem('ranked_token'); navigate('/'); return null; } + return r.json(); + }) + .then(data => { + console.log('[queue] force match result:', JSON.stringify(data)); + if (!data) return; + joinedRef.current = true; + if (data.status === 'waiting') { + pollQueue(); + } else if (data.status === 'matched') { + fetchMatchData(data.gameId); + } + }) + .catch(() => {}); + } + + // Fetch own user info + useEffect(() => { + fetch(`${API}/auth/me`, { headers: authHeaders() }) + .then(r => r.ok ? r.json() : null) + .then(data => { + if (!data) return; + setMyName(data.username ?? data.displayName ?? 'You'); + setMyAvatar(data.avatarUrl ?? null); + }) + .catch(() => {}); + }, []); + + // Join queue on mount + useEffect(() => { + // Cancel any pending leave from a previous unmount (Strict Mode guard) + if (leaveTimerRef.current) { + clearTimeout(leaveTimerRef.current); + leaveTimerRef.current = null; + } + + let cancelled = false; + + console.log('[queue] joining with grade', grade); + fetch(`${API}/queue/join`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ grade }), + }) + .then(r => { + if (r.status === 401) { console.log('[queue] 401 on join'); sessionStorage.removeItem('ranked_token'); navigate('/'); return null; } + return r.json(); + }) + .then(data => { + console.log('[queue] join response:', JSON.stringify(data)); + if (!data || cancelled) return; + joinedRef.current = true; + if (data.status === 'waiting') { + pollQueue(); + } else if (data.status === 'matched') { + fetchMatchData(data.gameId); + } + }) + .catch(e => console.log('[queue] join error:', e)); + + return () => { + cancelled = true; + clearInterval(pollRef.current); + if (joinedRef.current && !gameIdRef.current && !completedRef.current) { + leaveTimerRef.current = setTimeout(() => { + fetch(`${API}/queue/leave`, { method: 'DELETE', headers: authHeaders() }).catch(() => {}); + }, 300); + } + }; + }, [grade, navigate]); + + function pollQueue() { + pollRef.current = setInterval(() => { + fetch(`${API}/queue/status`, { headers: authHeaders() }) + .then(r => { + if (r.status === 401) { console.log('[queue] 401 on poll'); sessionStorage.removeItem('ranked_token'); navigate('/'); return null; } + return r.json(); + }) + .then(data => { + if (!data) return; + if (data.status === 'matched') { + console.log('[queue] poll found match!'); + clearInterval(pollRef.current); + gameIdRef.current = data.gameId; + setQuestions(data.questions); + setOpponentName(data.opponent.username ?? data.opponent.displayName); + setOpponentAvatar(data.opponent.avatarUrl); + setPhase('intro'); + } else if (data.status === 'not_in_queue') { + console.log('[queue] poll got not_in_queue, rejoining'); + clearInterval(pollRef.current); + fetch(`${API}/queue/join`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ grade }), + }) + .then(r => { + if (r.status === 401) { console.log('[queue] 401 on rejoin'); sessionStorage.removeItem('ranked_token'); navigate('/'); return null; } + return r.json(); + }) + .then(d => { + console.log('[queue] rejoin response:', JSON.stringify(d)); + if (!d) return; + if (d.status === 'waiting') pollQueue(); + else if (d.status === 'matched') fetchMatchData(d.gameId); + }) + .catch(() => {}); + } + }) + .catch(() => {}); + }, 1500); + } + + function fetchMatchData(gameId) { + gameIdRef.current = gameId; + fetch(`${API}/queue/status`, { headers: authHeaders() }) + .then(r => r.json()) + .then(data => { + if (data.status === 'matched') { + setQuestions(data.questions); + setOpponentName(data.opponent.username ?? data.opponent.displayName); + setOpponentAvatar(data.opponent.avatarUrl); + setPhase('intro'); + } + }) + .catch(() => {}); + } + + function submitProgress(currentQuestion, correctAnswers, finished, timeSpentMs) { + fetch(`${API}/game/progress`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ gameId: gameIdRef.current, currentQuestion, correctAnswers, finished, timeSpentMs }), + }).catch(() => {}); + } + + function pollGameStatus() { + const id = gameIdRef.current; + if (!id) return; + + pollRef.current = setInterval(() => { + if (completedRef.current) { clearInterval(pollRef.current); return; } + + fetch(`${API}/game/status/${id}`, { headers: authHeaders() }) + .then(r => r.json()) + .then(data => { + if (data.status === 'completed') { + clearInterval(pollRef.current); + const you = data.players.you; + const won = data.youWon === true; + completedRef.current = true; + setDuration(you.timeSpentMs || (Date.now() - startTime)); + setEloChange(you.eloChange || 0); + setPlayerWon(won); + if (won) { + setPhase('complete'); + } else { + setShowDefeatOverlay(true); + } + } else { + const opp = data.players.opponent; + if (opp.username) setOpponentName(opp.username); + setOpponentProgress(opp.currentQuestion + 1); + } + }) + .catch(() => {}); + }, 1000); + } + + function handleNext() { + const newAnswers = { ...answers, [qIdx]: selected }; + setAnswers(newAnswers); + + const correctCount = questions.filter((q, i) => newAnswers[i] === q.correct).length; + const elapsed = Date.now() - startTime; + + if (qIdx < STAGE_SIZE - 1) { + submitProgress(qIdx + 1, correctCount, false); + const next = qIdx + 1; + setQIdx(next); + setSelected(newAnswers[next] ?? null); + } else { + submittedRef.current = true; + submitProgress(STAGE_SIZE, correctCount, true, elapsed); + setPhase('waiting'); + } + } + + function handleBack() { + const newAnswers = selected !== null ? { ...answers, [qIdx]: selected } : answers; + setAnswers(newAnswers); + const prev = qIdx - 1; + setQIdx(prev); + setSelected(newAnswers[prev] ?? null); + } + + function handleJump(targetIdx) { + const newAnswers = selected !== null ? { ...answers, [qIdx]: selected } : answers; + setAnswers(newAnswers); + setQIdx(targetIdx); + setSelected(newAnswers[targetIdx] ?? null); + } + + if (!VALID_GRADES.has(grade)) return ; + + if (phase === 'finding') + return navigate('/dashboard')} showForceMatch={showForceMatch} onForceMatch={forceMatch} />; + + if (phase === 'intro') + return { setPhase('question'); pollGameStatus(); }} />; + + if (phase === 'question') + return ( + <> + + {showDefeatOverlay && ( + { setShowDefeatOverlay(false); setPhase('complete'); }} /> + )} + + ); + + if (phase === 'waiting') + return ( + <> + + {showDefeatOverlay && ( + { setShowDefeatOverlay(false); setPhase('complete'); }} /> + )} + + ); + + if (phase === 'complete') + return ( + navigate('/dashboard')} + /> + ); + + return null; +} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx new file mode 100644 index 0000000..6d59387 --- /dev/null +++ b/frontend/src/pages/Settings.jsx @@ -0,0 +1,42 @@ +import { useNavigate } from 'react-router-dom'; +import { useTheme } from '../context/ThemeContext'; + +export default function Settings() { + const navigate = useNavigate(); + const { theme, toggle } = useTheme(); + + return ( + <> +
+ + + +
+
+

Settings

+ +
+
+
+ Theme + Switch between dark and light mode +
+ +
+
+
+ +
+ + ); +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..65d8168 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2023", + "module": "esnext", + "lib": ["ES2023", "DOM"], + "types": ["vite/client"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..0466183 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/notes.txt b/notes.txt new file mode 100644 index 0000000..2e7b542 --- /dev/null +++ b/notes.txt @@ -0,0 +1,11 @@ +Prisma DB: npx prisma studio +Run: cd frontend && npm run dev +Express server: node src/index.js + +KILL 3000: kill $(lsof -ti:3000) + +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 + +push at school: git push origin main +pull at school: git pull --rebase origin main \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f70bf4d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1180 @@ +{ + "name": "ranked-eqao", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ranked-eqao", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@prisma/client": "^5.22.0", + "cors": "^2.8.6", + "dotenv": "^17.4.2", + "express": "^4.22.1", + "jsonwebtoken": "^9.0.3", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "prisma": "^5.22.0" + } + }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9bea275 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "ranked-eqao", + "version": "1.0.0", + "description": "RANKED EQAO", + "main": "index.js", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "https://git.cornlab.cc/john/ranked-eqao.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "@prisma/client": "^5.22.0", + "cors": "^2.8.6", + "dotenv": "^17.4.2", + "express": "^4.22.1", + "jsonwebtoken": "^9.0.3", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "prisma": "^5.22.0" + } +} diff --git a/prisma/migrations/20260507153558_init/migration.sql b/prisma/migrations/20260507153558_init/migration.sql new file mode 100644 index 0000000..c3b4683 --- /dev/null +++ b/prisma/migrations/20260507153558_init/migration.sql @@ -0,0 +1,102 @@ +-- CreateEnum +CREATE TYPE "Subject" AS ENUM ('MATH', 'READING', 'WRITING'); + +-- CreateEnum +CREATE TYPE "Difficulty" AS ENUM ('EASY', 'MEDIUM', 'HARD'); + +-- CreateEnum +CREATE TYPE "SessionStatus" AS ENUM ('IN_PROGRESS', 'COMPLETED', 'ABANDONED'); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "googleId" TEXT NOT NULL, + "email" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "avatarUrl" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Question" ( + "id" TEXT NOT NULL, + "grade" INTEGER NOT NULL, + "subject" "Subject" NOT NULL, + "strand" TEXT NOT NULL, + "difficulty" "Difficulty" NOT NULL, + "questionText" TEXT NOT NULL, + "options" JSONB NOT NULL, + "correctAnswer" TEXT NOT NULL, + "explanation" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Question_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "GameSession" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "grade" INTEGER NOT NULL, + "subject" "Subject" NOT NULL, + "status" "SessionStatus" NOT NULL DEFAULT 'IN_PROGRESS', + "score" INTEGER NOT NULL DEFAULT 0, + "totalQuestions" INTEGER NOT NULL, + "correctAnswers" INTEGER NOT NULL DEFAULT 0, + "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completedAt" TIMESTAMP(3), + + CONSTRAINT "GameSession_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UserAnswer" ( + "id" TEXT NOT NULL, + "sessionId" TEXT NOT NULL, + "questionId" TEXT NOT NULL, + "selectedAnswer" TEXT NOT NULL, + "isCorrect" BOOLEAN NOT NULL, + "timeSpentMs" INTEGER NOT NULL, + "answeredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UserAnswer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LeaderboardEntry" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "grade" INTEGER NOT NULL, + "subject" "Subject" NOT NULL, + "bestScore" INTEGER NOT NULL, + "gamesPlayed" INTEGER NOT NULL, + "totalCorrect" INTEGER NOT NULL, + "rank" INTEGER, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LeaderboardEntry_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_googleId_key" ON "User"("googleId"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "LeaderboardEntry_userId_grade_subject_key" ON "LeaderboardEntry"("userId", "grade", "subject"); + +-- AddForeignKey +ALTER TABLE "GameSession" ADD CONSTRAINT "GameSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserAnswer" ADD CONSTRAINT "UserAnswer_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "GameSession"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserAnswer" ADD CONSTRAINT "UserAnswer_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "Question"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LeaderboardEntry" ADD CONSTRAINT "LeaderboardEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260507153955_grade_enum/migration.sql b/prisma/migrations/20260507153955_grade_enum/migration.sql new file mode 100644 index 0000000..9c040bb --- /dev/null +++ b/prisma/migrations/20260507153955_grade_enum/migration.sql @@ -0,0 +1,25 @@ +/* + Warnings: + + - Changed the type of `grade` on the `GameSession` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + - Changed the type of `grade` on the `LeaderboardEntry` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + - Changed the type of `grade` on the `Question` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + +*/ +-- CreateEnum +CREATE TYPE "Grade" AS ENUM ('G3', 'G6', 'G9'); + +-- AlterTable +ALTER TABLE "GameSession" DROP COLUMN "grade", +ADD COLUMN "grade" "Grade" NOT NULL; + +-- AlterTable +ALTER TABLE "LeaderboardEntry" DROP COLUMN "grade", +ADD COLUMN "grade" "Grade" NOT NULL; + +-- AlterTable +ALTER TABLE "Question" DROP COLUMN "grade", +ADD COLUMN "grade" "Grade" NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "LeaderboardEntry_userId_grade_subject_key" ON "LeaderboardEntry"("userId", "grade", "subject"); diff --git a/prisma/migrations/20260511120000_add_leaderboard_score/migration.sql b/prisma/migrations/20260511120000_add_leaderboard_score/migration.sql new file mode 100644 index 0000000..0d6d146 --- /dev/null +++ b/prisma/migrations/20260511120000_add_leaderboard_score/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "LeaderboardScore" ( + "id" TEXT NOT NULL, + "playerName" TEXT NOT NULL, + "grade" "Grade" NOT NULL, + "score" INTEGER NOT NULL, + "totalQuestions" INTEGER NOT NULL, + "timeSeconds" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "LeaderboardScore_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/migrations/20260525000000_add_username/migration.sql b/prisma/migrations/20260525000000_add_username/migration.sql new file mode 100644 index 0000000..23a2f7e --- /dev/null +++ b/prisma/migrations/20260525000000_add_username/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "username" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); diff --git a/prisma/migrations/20260525000001_rebuild_leaderboard_entry/migration.sql b/prisma/migrations/20260525000001_rebuild_leaderboard_entry/migration.sql new file mode 100644 index 0000000..41b1fce --- /dev/null +++ b/prisma/migrations/20260525000001_rebuild_leaderboard_entry/migration.sql @@ -0,0 +1,26 @@ +-- Drop stale table (old schema: subject, bestScore, rank — missing elo, bestTime) +DROP TABLE IF EXISTS "LeaderboardEntry"; + +-- Drop orphaned table from old migration +DROP TABLE IF EXISTS "LeaderboardScore"; + +-- Recreate with correct schema +CREATE TABLE "LeaderboardEntry" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "grade" "Grade" NOT NULL, + "elo" INTEGER NOT NULL DEFAULT 1000, + "bestTime" INTEGER, + "gamesPlayed" INTEGER NOT NULL DEFAULT 0, + "totalCorrect" INTEGER NOT NULL DEFAULT 0, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT NOW(), + + CONSTRAINT "LeaderboardEntry_pkey" PRIMARY KEY ("id") +); + +-- Unique per user+grade (no subject) +CREATE UNIQUE INDEX "LeaderboardEntry_userId_grade_key" ON "LeaderboardEntry"("userId", "grade"); + +-- Foreign key +ALTER TABLE "LeaderboardEntry" ADD CONSTRAINT "LeaderboardEntry_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260525000002_elo_default_800/migration.sql b/prisma/migrations/20260525000002_elo_default_800/migration.sql new file mode 100644 index 0000000..aad51f6 --- /dev/null +++ b/prisma/migrations/20260525000002_elo_default_800/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "LeaderboardEntry" ALTER COLUMN "elo" SET DEFAULT 800; diff --git a/prisma/migrations/20260525164258_apply_pending/migration.sql b/prisma/migrations/20260525164258_apply_pending/migration.sql new file mode 100644 index 0000000..4058915 --- /dev/null +++ b/prisma/migrations/20260525164258_apply_pending/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "LeaderboardEntry" ALTER COLUMN "updatedAt" DROP DEFAULT; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..a687c29 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,105 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum Grade { + G3 + G6 + G9 +} + +enum Subject { + MATH + READING + WRITING +} + +enum Difficulty { + EASY + MEDIUM + HARD +} + +enum SessionStatus { + IN_PROGRESS + COMPLETED + ABANDONED +} + +model User { + id String @id @default(cuid()) + googleId String @unique + email String @unique + displayName String + username String? @unique + avatarUrl String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + sessions GameSession[] + leaderboardEntries LeaderboardEntry[] +} + +model Question { + id String @id @default(cuid()) + grade Grade + subject Subject + strand String + difficulty Difficulty + questionText String + options Json + correctAnswer String + explanation String? + createdAt DateTime @default(now()) + + answers UserAnswer[] +} + +model GameSession { + id String @id @default(cuid()) + userId String + grade Grade + subject Subject + status SessionStatus @default(IN_PROGRESS) + score Int @default(0) + totalQuestions Int + correctAnswers Int @default(0) + startedAt DateTime @default(now()) + completedAt DateTime? + + user User @relation(fields: [userId], references: [id]) + answers UserAnswer[] +} + +model UserAnswer { + id String @id @default(cuid()) + sessionId String + questionId String + selectedAnswer String + isCorrect Boolean + timeSpentMs Int + answeredAt DateTime @default(now()) + + session GameSession @relation(fields: [sessionId], references: [id]) + question Question @relation(fields: [questionId], references: [id]) +} + +model LeaderboardEntry { + id String @id @default(cuid()) + userId String + grade Grade + elo Int @default(800) + bestTime Int? + gamesPlayed Int @default(0) + totalCorrect Int @default(0) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id]) + + @@unique([userId, grade]) +} diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..d3774e73b7bcda094673142c48363976345e925b GIT binary patch literal 6148 zcmeHKJ5Iwu5S_5NV%j#TSB*`Ev2JnTS?+@qCAFrqWe7bdhGOoTG{JzmX z<#b0Wj*LdM1dAz+d=9OPO>tE|PwnO7T=+DunHOtr-^DiY3~2^_Q@RJ+Ax1Nw-5r4q)uC6odz6Bo!#B#%(c-q{AP%xY%JSDCuPEm9dVyvT-{U zW3LW>WW&kCg4(MBs(`P+j+}P5{-15$|NBXLrV6M6|4IQ9B>kj^TXMCvc5_^7BeW|t pHjXO=S1B0SQjAzG#Ybq?;E#9!j2)JO@WAv(z{;SVD)6fcd;us0a)|%{ literal 0 HcmV?d00001 diff --git a/src/auth/google.js b/src/auth/google.js new file mode 100644 index 0000000..f8b42f3 --- /dev/null +++ b/src/auth/google.js @@ -0,0 +1,78 @@ +const passport = require('passport'); +const { Strategy: GoogleStrategy } = require('passport-google-oauth20'); +const jwt = require('jsonwebtoken'); +const db = require('../db'); + +async function generateUniqueUsername(base) { + const candidate = base.toLowerCase(); + const taken = await db.user.findFirst({ where: { username: candidate } }); + if (!taken) return candidate; + let attempts = 0; + while (attempts < 20) { + const suffix = String(Math.floor(1000 + Math.random() * 9000)); + const username = `${candidate}${suffix}`; + const collision = await db.user.findFirst({ where: { username } }); + if (!collision) return username; + attempts++; + } + return `${candidate}${Date.now()}`; +} + +passport.use( + new GoogleStrategy( + { + clientID: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + callbackURL: process.env.GOOGLE_CALLBACK_URL, + }, + async (_accessToken, _refreshToken, profile, done) => { + try { + const email = profile.emails?.[0]?.value ?? ''; + let user = await db.user.findUnique({ where: { googleId: profile.id } }); + + if (!user) { + const username = await generateUniqueUsername(email.split('@')[0]); + user = await db.user.create({ + data: { + googleId: profile.id, + email, + displayName: profile.displayName, + avatarUrl: profile.photos?.[0]?.value ?? null, + username, + }, + }); + } else { + user = await db.user.update({ + where: { googleId: profile.id }, + data: { + email, + displayName: profile.displayName, + avatarUrl: profile.photos?.[0]?.value ?? null, + }, + }); + if (!user.username) { + const username = await generateUniqueUsername((user.email ?? '').split('@')[0]); + user = await db.user.update({ where: { id: user.id }, data: { username } }); + } + } + + const token = jwt.sign( + { + userId: user.id, + email: user.email, + name: user.displayName, + picture: user.avatarUrl, + }, + process.env.JWT_SECRET, + { algorithm: 'HS256', expiresIn: '7d' } + ); + + done(null, { token }); + } catch (err) { + done(err); + } + } + ) +); + +module.exports = passport; diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..15f364c --- /dev/null +++ b/src/db.js @@ -0,0 +1,5 @@ +const { PrismaClient } = require('@prisma/client'); + +const db = new PrismaClient(); + +module.exports = db; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..7a69a2f --- /dev/null +++ b/src/index.js @@ -0,0 +1,559 @@ +require('dotenv').config(); + +const crypto = require('crypto'); +const express = require('express'); +const cors = require('cors'); +const passport = require('./auth/google'); +const authMiddleware = require('./middleware/auth'); +const db = require('./db'); +const { getUserRank, upsertLeaderboardEntry } = require('./services/leaderboard'); +const { calculateElo, DEFAULT_ELO } = require('./services/elo'); +const { generateStage } = require('./services/questions'); + +const VALID_GRADES = new Set(['G3', 'G6', 'G9']); +const USERNAME_RE = /^[a-zA-Z0-9_]{3,20}$/; +const STAGE_SIZE = 11; +const MIN_GAME_MS = 8000; // fastest a human can answer 11 questions +const DC_TIMEOUT_MS = 8000; // no heartbeat for this long → disconnected +const DC_GRACE_MS = 15000; // don't check until this long after game start + +// ── In-memory matchmaking ────────────────────────────────────── +const queue = {}; // { userId: { grade, joinedAt, displayName, avatarUrl } } +const playerGames = {}; // { userId: gameId } +const activeGames = {}; // { gameId: { ... } } + +function generateGameId() { + return crypto.randomBytes(8).toString('hex'); +} + +function computeGameResult(game) { + const players = Object.keys(game.players); + const p0 = game.players[players[0]]; + const p1 = game.players[players[1]]; + + if (p0.finished && p1.finished) { + return p0.timeSpentMs < p1.timeSpentMs ? players[0] : players[1]; + } + if (p0.finished) return players[0]; + if (p1.finished) return players[1]; + return null; +} + +async function completeGame(game, forcedWinnerId = null) { + if (game.completing || game.status !== 'active') return; + game.completing = true; + + const players = Object.keys(game.players); + const winnerId = forcedWinnerId ?? computeGameResult(game); + game.winner = winnerId; + game.completedAt = Date.now(); + + for (const userId of players) { + const p = game.players[userId]; + const won = userId === winnerId; + + const currentEntry = await db.leaderboardEntry.findUnique({ + where: { userId_grade: { userId, grade: game.grade } }, + }); + const currentElo = currentEntry?.elo ?? DEFAULT_ELO; + const otherId = players.find(id => id !== userId); + const otherEntry = await db.leaderboardEntry.findUnique({ + where: { userId_grade: { userId: otherId, grade: game.grade } }, + }); + const opponentElo = otherEntry?.elo ?? DEFAULT_ELO; + const result = calculateElo(currentElo, opponentElo, won); + + await upsertLeaderboardEntry(userId, game.grade, { + elo: result.newElo, + bestTime: p.timeSpentMs ?? undefined, + correctAnswers: p.correctAnswers, + }); + + p.eloChange = result.change; + p.newElo = result.newElo; + + delete playerGames[userId]; + } + + // Set completed only after ELO is written so polls see final eloChange + game.status = 'completed'; + + setTimeout(() => { + delete activeGames[game.id]; + }, 30000); +} + +// Disconnect detection — runs every 3s +setInterval(() => { + const now = Date.now(); + for (const game of Object.values(activeGames)) { + if (game.status !== 'active' || game.completing) continue; + if (now - game.startedAt < DC_GRACE_MS) continue; + + const players = Object.keys(game.players); + const gone = players.filter(id => { + const p = game.players[id]; + return !p.finished && now - (p.lastSeen ?? game.startedAt) > DC_TIMEOUT_MS; + }); + + if (gone.length === 2) { + // Both gone — cancel, no ELO change + game.completing = true; + game.status = 'completed'; + game.winner = null; + game.completedAt = now; + for (const id of players) delete playerGames[id]; + setTimeout(() => { delete activeGames[game.id]; }, 30000); + } else if (gone.length === 1) { + const winnerId = players.find(id => id !== gone[0]); + completeGame(game, winnerId); + } + } +}, 3000); + +// Cleanup stale queue entries (5min timeout) and hard-expired games every 30s +setInterval(() => { + const now = Date.now(); + for (const [userId, q] of Object.entries(queue)) { + if (now - q.joinedAt > 300000) delete queue[userId]; + } + for (const [id, game] of Object.entries(activeGames)) { + if (game.status === 'active' && now - game.startedAt > 300000) { + const players = Object.keys(game.players); + const p0 = game.players[players[0]]; + const p1 = game.players[players[1]]; + if (p0.finished || p1.finished) { + completeGame(game); + } else { + game.completing = true; + game.status = 'completed'; + game.winner = null; + game.completedAt = now; + for (const userId of players) delete playerGames[userId]; + delete activeGames[id]; + } + } + if (game.status === 'completed' && game.completedAt && now - game.completedAt > 60000) { + delete activeGames[id]; + } + } +}, 30000); + +const app = express(); + +app.use(express.json()); +app.use(cors({ origin: process.env.FRONTEND_URL, credentials: true })); +app.use(passport.initialize()); + +app.get('/auth/google', passport.authenticate('google', { + scope: ['profile', 'email'], + session: false, +})); + +app.get('/auth/google/callback', (req, res, next) => { + passport.authenticate('google', { session: false }, (err, result) => { + if (err || !result) { + console.error('[auth/google/callback] failure:', err); + return res.redirect(`${process.env.FRONTEND_URL}/?error=auth_failed`); + } + res.redirect(`${process.env.FRONTEND_URL}/dashboard?token=${result.token}`); + })(req, res, next); +}); + +app.get('/auth/me', authMiddleware, async (req, res) => { + try { + const user = await db.user.findUnique({ where: { id: req.user.userId } }); + if (!user) return res.status(404).json({ error: 'User not found' }); + res.json({ + id: user.id, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + email: user.email, + username: user.username, + }); + } catch (err) { + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.get('/leaderboard', async (req, res) => { + const { grade } = req.query; + if (!VALID_GRADES.has(grade)) { + return res.status(400).json({ error: 'grade must be one of G3, G6, G9' }); + } + try { + const entries = await db.leaderboardEntry.findMany({ + where: { grade, bestTime: { not: null } }, + orderBy: { bestTime: 'asc' }, + take: 10, + include: { + user: { select: { displayName: true, username: true, avatarUrl: true } }, + }, + }); + res.json(entries.map((e, i) => ({ + rank: i + 1, + displayName: e.user.displayName, + username: e.user.username, + avatarUrl: e.user.avatarUrl, + bestTime: e.bestTime, + }))); + } catch { + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.get('/me/stats', authMiddleware, async (req, res) => { + try { + const entries = await Promise.all( + ['G3', 'G6', 'G9'].map(async grade => { + const [eloResult, timeResult] = await Promise.all([ + getUserRank(req.user.userId, grade, 'elo'), + getUserRank(req.user.userId, grade, 'bestTime'), + ]); + const entry = eloResult?.entry ?? null; + return { + grade, + elo: entry?.elo ?? 800, + bestTime: entry?.bestTime ?? null, + gamesPlayed: entry?.gamesPlayed ?? 0, + totalCorrect: entry?.totalCorrect ?? 0, + eloRank: eloResult?.rank ?? null, + timeRank: timeResult?.rank ?? null, + }; + }) + ); + res.json({ entries }); + } catch { + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.post('/game/complete', authMiddleware, async (req, res) => { + const { grade, correctAnswers, totalQuestions, timeSpentMs, isRanked, won } = req.body; + + if (!VALID_GRADES.has(grade)) { + return res.status(400).json({ error: 'grade must be one of G3, G6, G9' }); + } + if (typeof correctAnswers !== 'number' || typeof totalQuestions !== 'number') { + return res.status(400).json({ error: 'correctAnswers and totalQuestions required' }); + } + + try { + const userId = req.user.userId; + const currentEntry = await db.leaderboardEntry.findUnique({ + where: { userId_grade: { userId, grade } }, + }); + + await upsertLeaderboardEntry(userId, grade, { + bestTime: typeof timeSpentMs === 'number' ? Math.round(timeSpentMs / 1000) : undefined, + correctAnswers, + }); + + res.json({ + elo: currentEntry?.elo ?? DEFAULT_ELO, + eloChange: 0, + bestTime: currentEntry?.bestTime ?? null, + gamesPlayed: (currentEntry?.gamesPlayed ?? 0) + 1, + totalCorrect: (currentEntry?.totalCorrect ?? 0) + correctAnswers, + }); + } catch (err) { + console.error('[game/complete] error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.put('/me/username', authMiddleware, async (req, res) => { + const { username } = req.body; + if (!username || !USERNAME_RE.test(username)) { + return res.status(400).json({ error: 'invalid' }); + } + const lower = username.toLowerCase(); + try { + const conflict = await db.user.findFirst({ + where: { username: lower, NOT: { id: req.user.userId } }, + }); + if (conflict) return res.status(409).json({ error: 'taken' }); + const updated = await db.user.update({ + where: { id: req.user.userId }, + data: { username: lower }, + }); + res.json({ username: updated.username }); + } catch { + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.get('/questions', authMiddleware, (req, res) => { + const { grade } = req.query; + if (!VALID_GRADES.has(grade)) { + return res.status(400).json({ error: 'grade must be one of G3, G6, G9' }); + } + res.json(generateStage(grade, 1)); +}); + +app.post('/session/start', authMiddleware, async (req, res) => { + const { grade, subject } = req.body; + const VALID_SUBJECTS = new Set(['MATH', 'READING', 'WRITING']); + if (!VALID_GRADES.has(grade) || !VALID_SUBJECTS.has(subject)) { + return res.status(400).json({ error: 'invalid grade or subject' }); + } + try { + const session = await db.gameSession.create({ + data: { userId: req.user.userId, grade, subject, totalQuestions: 22, status: 'IN_PROGRESS' }, + }); + res.json({ sessionId: session.id }); + } catch (err) { + console.error('[session/start]', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.post('/session/complete', authMiddleware, async (req, res) => { + const { sessionId, correctAnswers, totalQuestions } = req.body; + if (!sessionId) return res.status(400).json({ error: 'sessionId required' }); + try { + const session = await db.gameSession.findUnique({ where: { id: sessionId } }); + if (!session || session.userId !== req.user.userId) { + return res.status(404).json({ error: 'Session not found' }); + } + const total = totalQuestions ?? session.totalQuestions; + await db.gameSession.update({ + where: { id: sessionId }, + data: { + status: 'COMPLETED', + correctAnswers: correctAnswers ?? 0, + totalQuestions: total, + score: Math.round(((correctAnswers ?? 0) / (total || 1)) * 100), + completedAt: new Date(), + }, + }); + res.json({ ok: true }); + } catch (err) { + console.error('[session/complete]', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.post('/me/best-time', authMiddleware, async (req, res) => { + const { grade, timeMs } = req.body; + if (!VALID_GRADES.has(grade) || typeof timeMs !== 'number' || timeMs <= 0) { + return res.status(400).json({ error: 'Invalid grade or timeMs' }); + } + try { + const entry = await db.leaderboardEntry.findUnique({ + where: { userId_grade: { userId: req.user.userId, grade } }, + }); + const isNewBest = !entry || entry.bestTime === null || timeMs < entry.bestTime; + const updated = await db.leaderboardEntry.upsert({ + where: { userId_grade: { userId: req.user.userId, grade } }, + create: { userId: req.user.userId, grade, elo: 800, bestTime: timeMs, gamesPlayed: 1 }, + update: { + ...(isNewBest ? { bestTime: timeMs } : {}), + gamesPlayed: { increment: 1 }, + }, + }); + res.json({ updated: isNewBest, bestTime: updated.bestTime }); + } catch { + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// ── Matchmaking ──────────────────────────────────────────────── + +app.post('/queue/join', authMiddleware, async (req, res) => { + const { grade } = req.body; + if (!VALID_GRADES.has(grade)) { + return res.status(400).json({ error: 'grade must be one of G3, G6, G9' }); + } + + try { + if (playerGames[req.user.userId]) { + return res.json({ status: 'matched', gameId: playerGames[req.user.userId] }); + } + + if (queue[req.user.userId]) { + return res.json({ status: 'waiting' }); + } + + const user = await db.user.findUnique({ where: { id: req.user.userId } }); + if (!user) return res.status(404).json({ error: 'User not found' }); + + console.log(`[queue/join] user=${req.user.userId} grade=${grade} queue_size=${Object.keys(queue).length} queue_keys=${JSON.stringify(Object.keys(queue))}`); + const existing = Object.entries(queue).find(([id, q]) => id !== req.user.userId && q.grade === grade); + + if (existing) { + const [opponentId, opponentQueue] = existing; + delete queue[opponentId]; + const gameGrade = opponentQueue.grade; + console.log(`[queue] matched ${req.user.userId} vs ${opponentId} for ${gameGrade}`); + + const questions = generateStage(gameGrade, 1); + const gameId = generateGameId(); + const game = { + id: gameId, + grade: gameGrade, + questions, + 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() }, + [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() }, + }, + status: 'active', + winner: null, + startedAt: Date.now(), + completedAt: null, + }; + activeGames[gameId] = game; + playerGames[opponentId] = gameId; + playerGames[req.user.userId] = gameId; + + return res.json({ status: 'matched', gameId }); + } + + queue[req.user.userId] = { grade, joinedAt: Date.now(), displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl }; + res.json({ status: 'waiting' }); + } catch (err) { + console.error('[queue/join] error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.delete('/queue/leave', authMiddleware, (req, res) => { + delete queue[req.user.userId]; + res.json({ success: true }); +}); + +app.get('/queue/debug', authMiddleware, (_req, res) => { + res.json({ + queueSize: Object.keys(queue).length, + queue: Object.entries(queue).map(([id, q]) => ({ id: id.slice(0,8), grade: q.grade, joinedAgo: Date.now() - q.joinedAt })), + activeGames: Object.keys(activeGames).length, + playerGames: Object.keys(playerGames).length, + }); +}); + +app.get('/queue/status', authMiddleware, async (req, res) => { + const gameId = playerGames[req.user.userId]; + + if (gameId) { + const game = activeGames[gameId]; + if (!game) { + delete playerGames[req.user.userId]; + return res.json({ status: 'not_in_queue' }); + } + + const opponentId = Object.keys(game.players).find(id => id !== req.user.userId); + const opponent = game.players[opponentId]; + + if (!opponent) { + delete playerGames[req.user.userId]; + delete activeGames[gameId]; + 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({ + status: 'matched', + gameId, + questions: game.questions, + opponent: { + username: opponentUser?.username ?? opponentUser?.displayName ?? opponent.displayName, + avatarUrl: opponentUser?.avatarUrl ?? opponent.avatarUrl, + }, + }); + } + + if (queue[req.user.userId]) { + return res.json({ status: 'waiting' }); + } + + res.json({ status: 'not_in_queue' }); +}); + +// ── Game management ──────────────────────────────────────────── + +app.post('/game/progress', authMiddleware, (req, res) => { + const { gameId, currentQuestion, correctAnswers, finished, timeSpentMs } = req.body; + const game = activeGames[gameId]; + + if (!game || game.status !== 'active') { + return res.status(404).json({ error: 'Game not found or already completed' }); + } + + const player = game.players[req.user.userId]; + if (!player) { + return res.status(403).json({ error: 'Not a participant in this game' }); + } + + if (player.finished) return res.json({ success: true }); // idempotent + + player.lastSeen = Date.now(); + + if (currentQuestion !== undefined) { + player.currentQuestion = Math.max(0, Math.min(STAGE_SIZE, Math.floor(Number(currentQuestion)))); + } + if (correctAnswers !== undefined) { + player.correctAnswers = Math.max(0, Math.min(STAGE_SIZE, Math.floor(Number(correctAnswers)))); + } + + if (finished) { + const elapsed = Date.now() - game.startedAt; + const claimed = typeof timeSpentMs === 'number' ? timeSpentMs : elapsed; + // 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.finished = true; + player.finishedAt = Date.now(); + + const winner = computeGameResult(game); + if (winner) completeGame(game); + } + + res.json({ success: true }); +}); + +app.get('/game/status/:gameId', authMiddleware, async (req, res) => { + const game = activeGames[req.params.gameId]; + if (!game) { + return res.status(404).json({ error: 'Game not found' }); + } + + const player = game.players[req.user.userId]; + if (!player) { + return res.status(403).json({ error: 'Not a participant in this game' }); + } + + player.lastSeen = Date.now(); + + const opponentId = Object.keys(game.players).find(id => id !== req.user.userId); + const opponent = game.players[opponentId]; + + const opponentUser = await db.user.findUnique({ where: { id: opponentId }, select: { username: true, displayName: true } }); + const opponentName = opponentUser?.username ?? opponentUser?.displayName ?? opponent.displayName; + + res.json({ + status: game.status, + youWon: game.winner !== null && game.winner === req.user.userId, + players: { + you: { + currentQuestion: player.currentQuestion, + correctAnswers: player.correctAnswers, + finished: player.finished, + timeSpentMs: player.timeSpentMs, + eloChange: player.eloChange, + newElo: player.newElo, + }, + opponent: { + username: opponentName, + currentQuestion: opponent.currentQuestion, + correctAnswers: opponent.correctAnswers, + finished: opponent.finished, + timeSpentMs: opponent.timeSpentMs, + }, + }, + }); +}); + +app.get('/health', (_req, res) => res.json({ status: 'ok' })); + +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => console.log(`Server running on port ${PORT}`)); \ No newline at end of file diff --git a/src/middleware/auth.js b/src/middleware/auth.js new file mode 100644 index 0000000..1e701d5 --- /dev/null +++ b/src/middleware/auth.js @@ -0,0 +1,19 @@ +const jwt = require('jsonwebtoken'); + +function authMiddleware(req, res, next) { + const header = req.headers.authorization; + if (!header || !header.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Missing authorization token' }); + } + + const token = header.slice(7); + try { + const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); + req.user = { userId: payload.userId, email: payload.email }; + next(); + } catch { + return res.status(401).json({ error: 'Invalid or expired token' }); + } +} + +module.exports = authMiddleware; diff --git a/src/services/elo.js b/src/services/elo.js new file mode 100644 index 0000000..8dd651d --- /dev/null +++ b/src/services/elo.js @@ -0,0 +1,16 @@ +const DEFAULT_ELO = 800; +const BASE_CHANGE = 10; +const DIFF_STEP = 30; +const MAX_CHANGE = 30; +const MIN_CHANGE = 1; + +// Custom ELO: base ±10, adjusted ±1 for every 30-point gap, clamped [1, 30]. +// Underdog who wins gains more; favourite who wins gains less. +function calculateElo(yourElo, opponentElo, won) { + const diff = opponentElo - yourElo; + const magnitude = Math.max(MIN_CHANGE, Math.min(MAX_CHANGE, BASE_CHANGE + Math.floor(diff / DIFF_STEP))); + const change = won ? magnitude : -magnitude; + return { newElo: yourElo + change, change }; +} + +module.exports = { calculateElo, DEFAULT_ELO }; diff --git a/src/services/leaderboard.js b/src/services/leaderboard.js new file mode 100644 index 0000000..d9fc0f5 --- /dev/null +++ b/src/services/leaderboard.js @@ -0,0 +1,73 @@ +const db = require('../db'); + +async function upsertLeaderboardEntry(userId, grade, { elo, bestTime, correctAnswers = 0 } = {}) { + const existing = await db.leaderboardEntry.findUnique({ + where: { userId_grade: { userId, grade } }, + }); + + return db.leaderboardEntry.upsert({ + where: { userId_grade: { userId, grade } }, + create: { + userId, + grade, + elo: elo ?? 800, + bestTime: bestTime ?? null, + gamesPlayed: 1, + totalCorrect: correctAnswers, + }, + update: { + ...(elo !== undefined && { elo }), + ...(bestTime !== undefined && existing?.bestTime == null || bestTime < (existing?.bestTime ?? Infinity) + ? { bestTime } + : {}), + gamesPlayed: { increment: 1 }, + totalCorrect: { increment: correctAnswers }, + }, + }); +} + +async function getLeaderboard(grade, sortBy = 'elo', limit = 10) { + const orderBy = sortBy === 'bestTime' + ? { bestTime: { sort: 'asc', nulls: 'last' } } + : { elo: 'desc' }; + + const entries = await db.leaderboardEntry.findMany({ + where: { grade }, + orderBy, + take: limit, + include: { + user: { select: { displayName: true, avatarUrl: true } }, + }, + }); + + return entries.map((entry, index) => ({ ...entry, rank: index + 1 })); +} + +async function getUserRank(userId, grade, sortBy = 'elo') { + const entry = await db.leaderboardEntry.findUnique({ + where: { userId_grade: { userId, grade } }, + }); + + if (!entry) return null; + + if (sortBy === 'bestTime') { + if (entry.bestTime === null) return { rank: null, entry }; + const count = await db.leaderboardEntry.count({ + where: { + grade, + AND: [ + { bestTime: { not: null } }, + { bestTime: { lt: entry.bestTime } }, + ], + }, + }); + return { rank: count + 1, entry }; + } + + const count = await db.leaderboardEntry.count({ + where: { grade, elo: { gt: entry.elo } }, + }); + return { rank: count + 1, entry }; +} + +module.exports = { upsertLeaderboardEntry, getLeaderboard, getUserRank }; diff --git a/src/services/questions.js b/src/services/questions.js new file mode 100644 index 0000000..9dc4409 --- /dev/null +++ b/src/services/questions.js @@ -0,0 +1,110 @@ +const rand = (a, b) => Math.floor(Math.random() * (b - a + 1)) + a; +const pick = arr => arr[Math.floor(Math.random() * arr.length)]; +const shuffle = arr => { + const a = [...arr]; + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +}; + +function makeQ(question, correct, wrongs) { + const ca = String(correct); + const ws = [...new Set(wrongs.map(String))].filter(w => w !== ca); + const choices = shuffle([ca, ...ws]); + return { question, choices, correct: choices.indexOf(ca), layout: choices.length === 3 ? 'list' : 'grid' }; +} + +const g3s1 = [ + () => { const p1 = rand(15,85), p2 = rand(10,75), t = p1+p2; const item1 = pick(['a pencil','an eraser','a ruler','a bookmark']), item2 = pick(['a sticker','a crayon','a marker','a folder']); return makeQ(`Sasha buys ${item1} for ${p1}¢ and ${item2} for ${p2}¢. How much does she pay altogether?`, `${t}¢`, [`${t+rand(5,15)}¢`,`${t-rand(3,10)}¢`,`${t+rand(20,35)}¢`]); }, + () => { const w = rand(2,6), t = w*7; return makeQ(`How many days are in ${w} weeks?`, t, [t+7, t-7, t+rand(1,3)]); }, + () => { const step = pick([2,5,10,3]), start = rand(1,8)*step, seq = [start,start+step,start+2*step,start+3*step], next = start+4*step; return makeQ(`What is the next number in this pattern?\n${seq.join(', ')}, ____`, next, [next+step, next-step, next+2*step]); }, + () => { const l = rand(3,12), w2 = rand(2,9), p = 2*(l+w2); return makeQ(`A rectangle is ${l} cm long and ${w2} cm wide. What is its perimeter?`, `${p} cm`, [`${p+4} cm`,`${p-4} cm`,`${l*w2} cm`]); }, + () => { const d = pick([2,3,4,6,8]), n = rand(1,d-1); return makeQ(`A pizza is cut into ${d} equal slices. ${n} slice${n>1?'s are':' is'} eaten. What fraction has been eaten?`, `${n}/${d}`, [`${n+1}/${d}`,`${n}/${d+1}`,`${d-n}/${d}`]); }, + () => { const g = rand(3,8), e = rand(2,9), t = g*e; const item = pick(['apples','books','stickers','marbles']); return makeQ(`There are ${g} bags with ${e} ${item} in each bag. How many ${item} are there in total?`, t, [t+e, t-e, g+e]); }, + () => { const [obj,unit,w1,w2,w3] = pick([['the length of a room','metres','centimetres','kilometres','millimetres'],['the weight of a watermelon','kilograms','grams','litres','metres'],['the length of an ant','millimetres','metres','kilometres','kilograms'],['the amount of water in a bathtub','litres','kilograms','metres','grams']]); return makeQ(`Which unit would you use to measure ${obj}?`, unit, [w1,w2,w3]); }, + () => { const sh = rand(8,11), dur = rand(1,4), eh = sh+dur; const item = pick(['movie','soccer game','music class','swimming lesson']); return makeQ(`A ${item} starts at ${sh}:00 and ends at ${eh}:00. How long does it last?`, `${dur} hour${dur>1?'s':''}`, [`${dur+1} hours`,`${dur>1?dur-1:dur+2} hours`,`${dur+2} hours`]); }, + () => { const total = rand(40,120), used = rand(10,total-10), left = total-used; const item = pick(['stickers','crayons','cards','beads']); return makeQ(`Jamal had ${total} ${item}. He gave ${used} to his friend. How many does he have now?`, left, [left+rand(5,10), left>5?left-rand(3,5):left+15, total+used]); }, + () => { const n = rand(10,99); const isEven = n%2===0; return makeQ(`Is the number ${n} even or odd?`, isEven?'Even':'Odd', isEven?['Odd','Neither','Both']:['Even','Neither','Both']); }, + () => { const animals=['dogs','cats','birds'], counts=[rand(3,9),rand(3,9),rand(3,9)], total=counts.reduce((a,b)=>a+b,0), table=animals.map((a,i)=>`${a}: ${counts[i]}`).join(' | '); return makeQ(`A survey counted pets:\n${table}\nHow many pets were counted in total?`, total, [total+rand(2,8), total-rand(2,5), counts[0]+counts[1]]); }, +]; + +const g3s2 = [ + () => { const l = rand(3,7), w2 = rand(2,6), area = l*w2; return makeQ(`A rectangle is ${l} units long and ${w2} units wide. What is its area?`, `${area} square units`, [`${area+l} square units`,`${2*(l+w2)} square units`,`${area-w2} square units`]); }, + () => { const start = rand(2,15), rule = rand(3,9), seq = [start,start+rule,start+2*rule,start+3*rule]; return makeQ(`This pattern follows a rule: ${seq.join(', ')} ...\nWhat is the rule?`, `Add ${rule}`, [`Add ${rule+1}`,`Add ${rule-1}`,`Multiply by 2`]); }, + () => { const b = pick([4,6,8]); const a = rand(1,b-1); const d = pick([4,6,8]); let c = rand(1,d-1); let tries=0; while(a*d===c*b && tries<20){c=rand(1,d-1);tries++;} const bigger = a/b>c/d?`${a}/${b}`:`${c}/${d}`, smaller = a/b>c/d?`${c}/${d}`:`${a}/${b}`; return makeQ(`Which fraction is larger: ${a}/${b} or ${c}/${d}?`, bigger, [smaller,'They are equal','Cannot tell']); }, + () => { const [shape,faces] = pick([['cube',6],['rectangular prism',6],['triangular prism',5],['square pyramid',5]]); return makeQ(`How many faces does a ${shape} have?`, faces, [faces+1, faces-1, faces+2]); }, + () => { const a = rand(3,9), b = rand(3,9), product = a*b; return makeQ(`What is ${a} × ${b}?`, product, [product+b, product-a, (a+1)*b]); }, + () => { const price = rand(25,95), paid = Math.ceil(price/25)*25+25, change = paid-price; return makeQ(`An item costs ${price}¢. You pay ${paid}¢. How much change do you get?`, `${change}¢`, [`${change+5}¢`,`${change-5}¢`,`${change+10}¢`]); }, + () => { const sh = rand(9,12), m = pick([0,30]), dur = rand(1,3), eh = sh+dur; const fmt = (h,mm) => `${h}:${mm===0?'00':mm}`; return makeQ(`A library visit starts at ${fmt(sh,m)} and lasts ${dur} hour${dur>1?'s':''}. What time does it end?`, fmt(eh,m), [fmt(eh+1,m),fmt(eh-1,m),fmt(eh,m===0?30:0)]); }, + () => { const rows = rand(3,6), cols = rand(3,6), extra = rand(2,8), total = rows*cols+extra; const item = pick(['chairs','books','tiles','eggs']); return makeQ(`There are ${rows} rows of ${cols} ${item} and ${extra} more on the side. How many ${item} altogether?`, total, [rows*cols, total+extra, total-rows]); }, + () => { const n = rand(12,97), rounded = Math.round(n/10)*10; return makeQ(`Round ${n} to the nearest 10.`, rounded, [rounded+10, rounded-10>=0?rounded-10:rounded+20, n]); }, + () => { const n = rand(2,10)*4, quarter = n/4; return makeQ(`What is one quarter of ${n}?`, quarter, [n/2, quarter*3, quarter+1]); }, + () => { const a = rand(100,499), b = rand(100,499), total = a+b; return makeQ(`What is ${a} + ${b}?`, total, [total+10, total-10, total+100]); }, +]; + +const g6s1 = [ + () => { const rate = rand(3,12), time = rand(4,10), total = rate*time; const item = pick(['pages','laps','problems','push-ups']); return makeQ(`Priya reads ${rate} ${item} per hour. How many will she read in ${time} hours?`, total, [total+rate, total-rate, rate+time]); }, + () => { const pct = pick([10,20,25,50]), whole = rand(2,20)*10, result = whole*pct/100; return makeQ(`What is ${pct}% of ${whole}?`, result, [result+5, result*2, whole-result]); }, + () => { const base = rand(4,14)*2, height = rand(3,10)*2, area = (base*height)/2; return makeQ(`A triangle has a base of ${base} cm and a height of ${height} cm. What is its area?`, `${area} cm²`, [`${base*height} cm²`,`${area+base} cm²`,`${area-height} cm²`]); }, + () => { const bp = rand(2,5), rp = rand(2,5), mult = rand(2,5), blue = bp*mult, red = rp*mult; return makeQ(`For every ${bp} blue marbles there are ${rp} red marbles. If there are ${blue} blue marbles, how many red marbles are there?`, red, [red+rp, red-rp, red+mult]); }, + () => { const a = rand(2,8), b = rand(2,6), c = rand(2,10), result = a+b*c; return makeQ(`Evaluate: ${a} + ${b} × ${c}`, result, [(a+b)*c, result+b, result-a]); }, + () => { const l = rand(2,8), w2 = rand(2,6), h = rand(2,5), vol = l*w2*h; return makeQ(`What is the volume of a rectangular prism with length ${l} cm, width ${w2} cm, and height ${h} cm?`, `${vol} cm³`, [`${vol+l*w2} cm³`,`${2*(l*w2+l*h+w2*h)} cm³`,`${l+w2+h} cm³`]); }, + () => { const d = pick([2,3,4,5]), whole = d*rand(3,8), result = whole/d; return makeQ(`What is 1/${d} of ${whole}?`, result, [result+d, result*d, result-1]); }, + () => { const a = rand(-8,-1), b = rand(1,10), sum = a+b; return makeQ(`What is ${a} + ${b}?`, sum, [sum+2, sum-2, Math.abs(a)+b]); }, + () => { const vals = [rand(4,12),rand(4,12),rand(4,12),rand(4,12)]; const s = vals.reduce((a,b)=>a+b,0); const adj = s%4; if(adj!==0) vals[3]+=4-adj; const s2 = vals.reduce((a,b)=>a+b,0), mean = s2/4; return makeQ(`Find the mean of: ${vals.join(', ')}`, mean, [mean+2, mean-1, s2]); }, + () => { const side = rand(3,15), perim = 4*side; return makeQ(`A square has a perimeter of ${perim} cm. What is the length of one side?`, `${side} cm`, [`${side+2} cm`,`${side-1} cm`,`${perim/2} cm`]); }, + () => { const a = rand(1,9)+rand(1,9)/10, b = rand(1,9)+rand(1,9)/10, total = Math.round((a+b)*10)/10; return makeQ(`What is ${a.toFixed(1)} + ${b.toFixed(1)}?`, total.toFixed(1), [(total+0.5).toFixed(1),(total-0.3).toFixed(1),(total+1).toFixed(1)]); }, +]; + +const g6s2 = [ + () => { const total = rand(4,20)*10, pct = pick([10,20,25,50]), part = total*pct/100; return makeQ(`In a class of ${total} students, ${pct}% walk to school. How many students walk?`, part, [part+5, part-5, total-part]); }, + () => { const km = rand(2,15), m = km*1000; return makeQ(`A hiking trail is ${km} km long. How many metres is this?`, `${m} m`, [`${m+100} m`,`${km*100} m`,`${m-500} m`]); }, + () => { const x = rand(3,15), mult = rand(2,6), product = mult*x; return makeQ(`Solve for n: ${mult}n = ${product}`, x, [x+1, x-1, mult+x]); }, + () => { const total = pick([4,5,6,8,10]), fav = rand(1,total-1); return makeQ(`A bag has ${total} marbles: ${fav} red and ${total-fav} blue. What is the probability of picking red?`, `${fav}/${total}`, [`${fav+1}/${total}`,`${total-fav}/${total}`,`${fav}/${total-1}`]); }, + () => { const deg = pick([30,45,60,90,120,150]); const type = deg<90?'acute':deg===90?'right':'obtuse'; return makeQ(`An angle measures ${deg}°. What type of angle is it?`, type, ['acute','right','obtuse','reflex'].filter(t=>t!==type).slice(0,3)); }, + () => { const boys = rand(3,8), girls = rand(3,8), total = boys+girls; return makeQ(`The ratio of boys to girls is ${boys}:${girls}. What fraction of the group are boys?`, `${boys}/${total}`, [`${boys}/${girls}`,`${girls}/${total}`,`${boys+1}/${total}`]); }, + () => { const base = pick([2,3,4,5]), exp = pick([2,3]), result = Math.pow(base,exp); return makeQ(`What is ${base}^${exp}?`, result, [base*exp, result+base, result-base]); }, + () => { const d = pick([4,6,8,12]); const n1 = rand(Math.floor(d/2)+1,d-1), n2 = rand(1,Math.floor(d/2)), diff = n1-n2; const gcd = (a,b) => b===0?a:gcd(b,a%b); const g = gcd(diff,d); const ans = `${diff/g}/${d/g}`; return makeQ(`What is ${n1}/${d} − ${n2}/${d}? (simplify if possible)`, ans, [`${diff+1}/${d}`,`${n1}/${d}`,`${diff}/${d+2}`]); }, + () => { const r = rand(3,10), circ = Math.round(2*3.14*r); return makeQ(`A circle has radius ${r} cm. What is its approximate circumference? (π ≈ 3.14)`, `${circ} cm`, [`${Math.round(3.14*r*r)} cm`,`${circ+6} cm`,`${Math.round(2*3.14*(r+1))} cm`]); }, + () => { const rise = rand(1,5), run = rand(1,5); return makeQ(`A line rises ${rise} units for every ${run} units to the right. What is the slope?`, `${rise}/${run}`, [`${run}/${rise}`,`${rise+1}/${run}`,`${rise}/${run+1}`]); }, + () => { const original = rand(4,20)*10, discount = pick([10,20,25]), saved = original*discount/100, final = original-saved; return makeQ(`A jacket costs $${original}. It is ${discount}% off. What is the sale price?`, `$${final}`, [`$${saved}`,`$${final+10}`,`$${final-5}`]); }, +]; + +const g9s1 = [ + () => { const x = rand(2,12), a = rand(2,7), b = rand(1,20), lhs = a*x+b; return makeQ(`Solve for x: ${a}x + ${b} = ${lhs}`, `x = ${x}`, [`x = ${x+1}`,`x = ${x-1}`,`x = ${lhs}`]); }, + () => { const [a,b,c] = pick([[3,4,5],[5,12,13],[8,15,17],[6,8,10]]); return makeQ(`A right triangle has legs of ${a} and ${b}. What is the hypotenuse?`, c, [c+1, c-1, a+b]); }, + () => { const x1=rand(0,4),y1=rand(0,4),rise=rand(1,5),run=rand(1,5); return makeQ(`Find the slope of the line through (${x1}, ${y1}) and (${x1+run}, ${y1+rise}).`, `${rise}/${run}`, [`${run}/${rise}`,`${rise+1}/${run}`,`-${rise}/${run}`]); }, + () => { const a = rand(2,6), b = rand(1,8); return makeQ(`Expand: ${a}(x + ${b})`, `${a}x + ${a*b}`, [`${a}x + ${b}`,`${a+b}x`,`${a}x + ${a*b+1}`]); }, + () => { const original = rand(4,20)*10, pct = pick([10,15,20,25,30]), increase = original*pct/100, final = original+increase; return makeQ(`A price of $${original} increases by ${pct}%. What is the new price?`, `$${final}`, [`$${final+10}`,`$${original-increase}`,`$${final-5}`]); }, + () => { const a = rand(4,10), b = rand(6,14), h = rand(3,8), area = (a+b)*h/2; return makeQ(`A trapezoid has parallel sides of ${a} cm and ${b} cm, and height ${h} cm. What is its area?`, `${area} cm²`, [`${a*b*h/2} cm²`,`${area+h} cm²`,`${(a+b)*h} cm²`]); }, + () => { const base = pick([2,3,5]), e1 = rand(2,5), e2 = rand(2,4); return makeQ(`Simplify: ${base}^${e1} × ${base}^${e2}`, `${base}^${e1+e2}`, [`${base}^${e1*e2}`,`${base}^${e1+e2+1}`,`${base*2}^${e1+e2}`]); }, + () => { const m = rand(2,5), b = rand(1,8), x = rand(3,8), y = m*x+b; return makeQ(`If y = ${m}x + ${b}, what is y when x = ${x}?`, y, [y+m, y-m, m*x]); }, + () => { const x = rand(3,12), a = rand(2,5), b = rand(1,15), rhs = a*x-b; return makeQ(`Solve for x: ${a}x − ${b} = ${rhs}`, `x = ${x}`, [`x = ${x+1}`,`x = ${x-1}`,`x = ${rhs}`]); }, + () => { const s = rand(3,8), sa = 6*s*s; return makeQ(`A cube has side length ${s} cm. What is its surface area?`, `${sa} cm²`, [`${s*s*s} cm³`,`${sa+s*s} cm²`,`${4*s*s} cm²`]); }, + () => { const die = rand(1,5); const coin = pick(['heads','tails']); return makeQ(`A coin is flipped and a 6-sided die is rolled. What is the probability of getting ${coin} and rolling less than ${die+1}?`, `${die}/12`, [`${die}/6`,`1/${die+1}`,`${die+1}/12`]); }, +]; + +const g9s2 = [ + () => { const r = rand(2,6), s = rand(2,5), bc = r+s, cc = r*s; return makeQ(`Factor: x² + ${bc}x + ${cc}`, `(x + ${r})(x + ${s})`, [`(x + ${bc})(x + 1)`,`(x + ${r+1})(x + ${s-1})`,`(x − ${r})(x − ${s})`]); }, + () => { const p = rand(5,20)*100, rate = pick([5,10,20]), interest = p*rate/100, total = p+interest; return makeQ(`$${p} is invested at ${rate}% simple interest for 1 year. What is the total after 1 year?`, `$${total}`, [`$${interest}`,`$${total+50}`,`$${p*2}`]); }, + () => { const m = rand(1,4), b = rand(-5,8); const bs = b>=0?`+ ${b}`:`− ${Math.abs(b)}`; return makeQ(`What is the y-intercept of y = ${m}x ${bs}?`, `(0, ${b})`, [`(${b}, 0)`,`(0, ${b+1})`,`(${m}, 0)`]); }, + () => { const [a,bc,c] = pick([[3,4,5],[5,12,13],[8,15,17]]); return makeQ(`A right triangle has hypotenuse ${c} and one leg ${a}. What is the other leg?`, bc, [bc+1, bc-1, Math.round(Math.sqrt(c*c+a*a))]); }, + () => { const x = rand(3,10), a = rand(2,5), b = rand(1,10), lhs = a*x+b; return makeQ(`Which value of x satisfies ${a}x + ${b} > ${lhs-1}?`, `x = ${x}`, [`x = ${x-1}`,`x = ${x-2}`,`x = 0`]); }, + () => { const rate = rand(3,8), t1 = rand(2,5), t2 = t1+rand(1,3); return makeQ(`A cyclist travels ${rate*t1} km in ${t1} hours at constant speed. How far in ${t2} hours?`, `${rate*t2} km`, [`${rate*t2+rate} km`,`${rate*t1+t2} km`,`${rate*t2-rate} km`]); }, + () => { const angle = rand(30,80); return makeQ(`Two parallel lines are cut by a transversal. One angle is ${angle}°. What is its co-interior angle?`, `${180-angle}°`, [`${angle}°`,`${180+angle}°`,`${90-angle}°`]); }, + () => { const coeff = rand(1,9), exp2 = rand(3,5), value = coeff*Math.pow(10,exp2); return makeQ(`Which equals ${coeff} × 10^${exp2}?`, String(value), [String(value*10),String(value/10),String(coeff+exp2)]); }, + () => { const r = rand(3,7), h = rand(4,10), vol = Math.round(3.14*r*r*h); return makeQ(`A cylinder has radius ${r} cm and height ${h} cm. Approximate its volume (π ≈ 3.14).`, `${vol} cm³`, [`${Math.round(3.14*r*h)} cm³`,`${Math.round(3.14*2*r*h)} cm³`,`${Math.round(3.14*r*r)} cm³`]); }, + () => { const [trend,desc] = pick([['positive','as x increases, y increases'],['negative','as x increases, y decreases'],['no correlation','x and y show no pattern']]); return makeQ(`A scatter plot shows: ${desc}. What type of correlation is this?`, trend, ['positive','negative','no correlation'].filter(t=>t!==trend)); }, + () => { const a = rand(1,3), b = rand(1,5), x = rand(2,5), result = a*x*x+b*x; return makeQ(`Evaluate ${a}x² + ${b}x when x = ${x}.`, result, [result+b, result-a, a*x+b]); }, +]; + +const QUESTION_BANK = { G3: [...g3s1, ...g3s2], G6: [...g6s1, ...g6s2], G9: [...g9s1, ...g9s2] }; +const STAGE_SIZE = 11; + +function generateStage(grade, stageNum) { + return QUESTION_BANK[grade].slice((stageNum - 1) * STAGE_SIZE, stageNum * STAGE_SIZE).map(fn => fn()); +} + +module.exports = { generateStage, STAGE_SIZE };