added server, db, auth system
This commit is contained in:
parent
4d3395fa1c
commit
199dab69f9
28 changed files with 989 additions and 192 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -33,3 +33,6 @@ devenv.local.yaml
|
||||||
|
|
||||||
# pre-commit
|
# pre-commit
|
||||||
.pre-commit-config.yaml
|
.pre-commit-config.yaml
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
|
|
||||||
25
CLAUDE.md
25
CLAUDE.md
|
|
@ -4,29 +4,40 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
|
|
||||||
## What This Is
|
## What This Is
|
||||||
|
|
||||||
A typing tutor app for a child (Leo), built with React 19 + TypeScript + Vite. Uses Bun as the package manager/runtime.
|
A typing tutor app for a child (Leo), built with React 19 + TypeScript + Bun fullstack server. Uses Bun as the package manager, runtime, bundler, and server.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- `bun run dev` — start dev server
|
- `bun run dev` — start dev server with HMR (`bun --hot server.ts`)
|
||||||
- `bun run build` — typecheck (`tsc -b`) then build for production
|
- `bun run build` — typecheck (`tsc -b`)
|
||||||
- `bun run lint` — ESLint
|
- `bun run lint` — ESLint
|
||||||
- `bun run preview` — serve production build locally
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
**Server** — `server.ts` is the entry point using `Bun.serve()` with HTML imports. It serves the React app and API routes. The HTML file (`index.html`) is the client entrypoint — Bun's built-in bundler processes all referenced scripts/styles automatically.
|
||||||
|
|
||||||
|
**Auth** — Passkey (WebAuthn) authentication via `@simplewebauthn/server` + `@simplewebauthn/browser`. Server-side auth logic in `server/auth.ts`. Sessions stored in SQLite via HTTP-only cookies.
|
||||||
|
|
||||||
|
**Database** — SQLite via `bun:sql` (`server/db.ts`). Tables: `users`, `credentials` (passkey public keys), `sessions`. DB file: `leo-typing.db` (gitignored).
|
||||||
|
|
||||||
|
**Frontend Auth Gate** — `src/components/AuthGate.tsx` wraps the app. Shows login/register screen if not authenticated, otherwise renders the main app. Uses render-prop pattern to pass `user` and `onLogout` to `App`.
|
||||||
|
|
||||||
**Modes** — The app has four tabs, each a top-level component rendered by `App.tsx`:
|
**Modes** — The app has four tabs, each a top-level component rendered by `App.tsx`:
|
||||||
- **LessonMode** — structured lessons with progressive key introduction; lessons unlock sequentially (90%+ accuracy required)
|
- **LessonMode** — structured lessons with progressive key introduction; lessons unlock sequentially (90%+ accuracy required)
|
||||||
- **FreeMode** — free typing practice using random quotes
|
- **FreeMode** — free typing practice using random quotes
|
||||||
- **GameMode** — arcade-style falling-words game with combo system and sound effects
|
- **GameMode** — game selector with two games:
|
||||||
|
- **Falling Words** — DOM-based falling words with combo system
|
||||||
|
- **Missile Strike** — Canvas 2D missile flight game with interceptor dodging
|
||||||
- **StatsView** — charts (recharts) showing WPM/accuracy trends and per-key heatmaps
|
- **StatsView** — charts (recharts) showing WPM/accuracy trends and per-key heatmaps
|
||||||
|
|
||||||
**Core hook: `useTypingEngine`** — shared typing logic used by LessonMode and FreeMode. Tracks cursor position, errors, WPM (updated every 500ms), per-key hit/miss stats, and fires `onComplete` when text is finished. GameMode has its own input handling since its mechanics differ (falling words, not linear text).
|
**Core hook: `useTypingEngine`** — shared typing logic used by LessonMode and FreeMode. Tracks cursor position, errors, WPM (updated every 500ms), per-key hit/miss stats, and fires `onComplete` when text is finished. GameMode has its own input handling since its mechanics differ.
|
||||||
|
|
||||||
**State: `useStats`** — persists `UserProgress` (completed lessons, session history, game high score) to localStorage under key `leo-typing-progress`.
|
**State: `useStats`** — persists `UserProgress` (completed lessons, session history, game high score) to localStorage under key `leo-typing-progress`.
|
||||||
|
|
||||||
**Sound: `sounds.ts`** — all audio is synthesized via Web Audio API oscillators (no audio files). Exports: `playKeyClick`, `playWordComplete`, `playCombo`, `playMiss`, `playGameOver`.
|
**Sound: `sounds.ts`** — all audio is synthesized via Web Audio API oscillators (no audio files). Exports: `playKeyClick`, `playWordComplete`, `playCombo`, `playMiss`, `playGameOver`, `playLaunch`, `playDodge`, `playMissileHit`, `playExplosion`.
|
||||||
|
|
||||||
**Data files** — `src/data/lessons.ts` (lesson definitions with word lists), `src/data/quotes.ts` (free mode texts), `src/data/keyboard.ts` (key-to-finger mapping for the visual keyboard).
|
**Data files** — `src/data/lessons.ts` (lesson definitions with word lists), `src/data/quotes.ts` (free mode texts), `src/data/keyboard.ts` (key-to-finger mapping for the visual keyboard).
|
||||||
|
|
||||||
**Styling** — CSS Modules (`src/styles/*.module.css`) plus a global stylesheet.
|
**Styling** — CSS Modules (`src/styles/*.module.css`) plus a global stylesheet.
|
||||||
|
|
||||||
|
**TypeScript config** — `tsconfig.app.json` covers `src/` (client code, DOM types), `tsconfig.server.json` covers `server.ts` + `server/` (Bun types).
|
||||||
|
|
|
||||||
49
bun.lock
49
bun.lock
|
|
@ -5,6 +5,8 @@
|
||||||
"": {
|
"": {
|
||||||
"name": "temp-scaffold",
|
"name": "temp-scaffold",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
|
"@simplewebauthn/server": "^13.3.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"recharts": "^3.8.0",
|
"recharts": "^3.8.0",
|
||||||
|
|
@ -15,6 +17,7 @@
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"bun-types": "^1.3.11",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
|
@ -82,6 +85,8 @@
|
||||||
|
|
||||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
||||||
|
|
||||||
|
"@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="],
|
||||||
|
|
||||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||||
|
|
||||||
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
||||||
|
|
@ -100,10 +105,36 @@
|
||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
|
||||||
|
|
||||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||||
|
|
||||||
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
|
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
|
||||||
|
|
||||||
|
"@peculiar/asn1-android": ["@peculiar/asn1-android@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ=="],
|
||||||
|
|
||||||
|
"@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/asn1-x509-attr": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw=="],
|
||||||
|
|
||||||
|
"@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w=="],
|
||||||
|
|
||||||
|
"@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g=="],
|
||||||
|
|
||||||
|
"@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.6.1", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.1", "@peculiar/asn1-pkcs8": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw=="],
|
||||||
|
|
||||||
|
"@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw=="],
|
||||||
|
|
||||||
|
"@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.6.1", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.1", "@peculiar/asn1-pfx": "^2.6.1", "@peculiar/asn1-pkcs8": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/asn1-x509-attr": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw=="],
|
||||||
|
|
||||||
|
"@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA=="],
|
||||||
|
|
||||||
|
"@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.6.0", "", { "dependencies": { "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg=="],
|
||||||
|
|
||||||
|
"@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA=="],
|
||||||
|
|
||||||
|
"@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ=="],
|
||||||
|
|
||||||
|
"@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="],
|
||||||
|
|
||||||
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
|
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
|
||||||
|
|
||||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.11", "", { "os": "android", "cpu": "arm64" }, "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A=="],
|
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.11", "", { "os": "android", "cpu": "arm64" }, "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A=="],
|
||||||
|
|
@ -138,6 +169,10 @@
|
||||||
|
|
||||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
|
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
|
||||||
|
|
||||||
|
"@simplewebauthn/browser": ["@simplewebauthn/browser@13.3.0", "", {}, "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ=="],
|
||||||
|
|
||||||
|
"@simplewebauthn/server": ["@simplewebauthn/server@13.3.0", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/x509": "^1.14.3" } }, "sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ=="],
|
||||||
|
|
||||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||||
|
|
@ -206,6 +241,8 @@
|
||||||
|
|
||||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
|
"asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="],
|
||||||
|
|
||||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.10", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ=="],
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.10", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ=="],
|
||||||
|
|
@ -214,6 +251,8 @@
|
||||||
|
|
||||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||||
|
|
||||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001781", "", {}, "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw=="],
|
"caniuse-lite": ["caniuse-lite@1.0.30001781", "", {}, "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw=="],
|
||||||
|
|
@ -420,6 +459,10 @@
|
||||||
|
|
||||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
|
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
|
||||||
|
|
||||||
|
"pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
|
||||||
|
|
||||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||||
|
|
||||||
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||||
|
|
@ -434,6 +477,8 @@
|
||||||
|
|
||||||
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
|
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
|
||||||
|
|
||||||
|
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
|
||||||
|
|
||||||
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
||||||
|
|
||||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||||
|
|
@ -462,6 +507,8 @@
|
||||||
|
|
||||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="],
|
||||||
|
|
||||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
@ -508,6 +555,8 @@
|
||||||
|
|
||||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.11", "", {}, "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ=="],
|
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.11", "", {}, "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ=="],
|
||||||
|
|
||||||
|
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
|
||||||
|
|
||||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
|
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
|
||||||
|
|
||||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,14 @@
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port 5174 --host",
|
"dev": "bun --hot server.ts",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"typecheck": "tsc -b"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
|
"@simplewebauthn/server": "^13.3.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"recharts": "^3.8.0"
|
"recharts": "^3.8.0"
|
||||||
|
|
@ -20,6 +22,7 @@
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"bun-types": "^1.3.11",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
|
|
||||||
34
server.ts
Normal file
34
server.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { serve } from "bun"
|
||||||
|
import { initDb } from "./server/db"
|
||||||
|
import {
|
||||||
|
registerOptions,
|
||||||
|
registerVerify,
|
||||||
|
loginOptions,
|
||||||
|
loginVerify,
|
||||||
|
me,
|
||||||
|
logout,
|
||||||
|
} from "./server/auth"
|
||||||
|
import index from "./index.html"
|
||||||
|
|
||||||
|
await initDb()
|
||||||
|
|
||||||
|
const server = serve({
|
||||||
|
port: 5174,
|
||||||
|
routes: {
|
||||||
|
"/*": index,
|
||||||
|
|
||||||
|
"/api/auth/register/options": { POST: registerOptions },
|
||||||
|
"/api/auth/register/verify": { POST: registerVerify },
|
||||||
|
"/api/auth/login/options": { POST: loginOptions },
|
||||||
|
"/api/auth/login/verify": { POST: loginVerify },
|
||||||
|
"/api/auth/me": { GET: me },
|
||||||
|
"/api/auth/logout": { POST: logout },
|
||||||
|
},
|
||||||
|
|
||||||
|
development: {
|
||||||
|
hmr: true,
|
||||||
|
console: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Server running on ${server.url}`)
|
||||||
280
server/auth.ts
Normal file
280
server/auth.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
import {
|
||||||
|
generateRegistrationOptions,
|
||||||
|
verifyRegistrationResponse,
|
||||||
|
generateAuthenticationOptions,
|
||||||
|
verifyAuthenticationResponse,
|
||||||
|
} from "@simplewebauthn/server"
|
||||||
|
import { isoBase64URL } from "@simplewebauthn/server/helpers"
|
||||||
|
import type {
|
||||||
|
RegistrationResponseJSON,
|
||||||
|
AuthenticationResponseJSON,
|
||||||
|
} from "@simplewebauthn/server"
|
||||||
|
import {
|
||||||
|
createUser,
|
||||||
|
getUserByUsername,
|
||||||
|
getUserById,
|
||||||
|
storeCredential,
|
||||||
|
getCredentialById,
|
||||||
|
updateCredentialCounter,
|
||||||
|
createSession,
|
||||||
|
getSession,
|
||||||
|
deleteSession,
|
||||||
|
} from "./db"
|
||||||
|
|
||||||
|
const RP_NAME = "Leo's Typing Tutor"
|
||||||
|
|
||||||
|
// Temporary challenge store (in-memory, keyed by random ID → challenge + metadata)
|
||||||
|
const challenges = new Map<string, { challenge: string; userId?: number; expires: number }>()
|
||||||
|
|
||||||
|
// Cleanup expired challenges periodically
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [key, val] of challenges) {
|
||||||
|
if (val.expires < now) challenges.delete(key)
|
||||||
|
}
|
||||||
|
}, 60_000)
|
||||||
|
|
||||||
|
function getRpId(req: Request): string {
|
||||||
|
const url = new URL(req.url)
|
||||||
|
return url.hostname
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrigin(req: Request): string {
|
||||||
|
const url = new URL(req.url)
|
||||||
|
return url.origin
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(req: Request, name: string): string | undefined {
|
||||||
|
const cookies = req.headers.get("cookie")
|
||||||
|
if (!cookies) return undefined
|
||||||
|
const match = cookies.split(";").find(c => c.trim().startsWith(`${name}=`))
|
||||||
|
return match?.split("=").slice(1).join("=").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionCookie(token: string, maxAge = 7 * 24 * 60 * 60): string {
|
||||||
|
return `session=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function challengeCookie(key: string): string {
|
||||||
|
return `challenge_key=${key}; HttpOnly; SameSite=Strict; Path=/; Max-Age=120`
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearChallengeCookie(): string {
|
||||||
|
return `challenge_key=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Route handlers ----
|
||||||
|
|
||||||
|
export async function registerOptions(req: Request): Promise<Response> {
|
||||||
|
const { username } = await req.json() as { username: string }
|
||||||
|
if (!username || username.length < 1 || username.length > 32) {
|
||||||
|
return Response.json({ error: "Username must be 1-32 characters" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if username already taken
|
||||||
|
const existing = await getUserByUsername(username)
|
||||||
|
if (existing) {
|
||||||
|
return Response.json({ error: "Username already taken" }, { status: 409 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
const user = await createUser(username)
|
||||||
|
|
||||||
|
const rpID = getRpId(req)
|
||||||
|
const options = await generateRegistrationOptions({
|
||||||
|
rpName: RP_NAME,
|
||||||
|
rpID,
|
||||||
|
userName: username,
|
||||||
|
userID: isoBase64URL.toBuffer(isoBase64URL.fromUTF8String(user.id.toString())),
|
||||||
|
attestationType: "none",
|
||||||
|
authenticatorSelection: {
|
||||||
|
residentKey: "required",
|
||||||
|
userVerification: "preferred",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Store challenge
|
||||||
|
const challengeKey = crypto.randomUUID()
|
||||||
|
challenges.set(challengeKey, {
|
||||||
|
challenge: options.challenge,
|
||||||
|
userId: user.id,
|
||||||
|
expires: Date.now() + 120_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(options), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Set-Cookie": challengeCookie(challengeKey),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerVerify(req: Request): Promise<Response> {
|
||||||
|
const body = (await req.json()) as RegistrationResponseJSON
|
||||||
|
const challengeKey = getCookie(req, "challenge_key")
|
||||||
|
if (!challengeKey) {
|
||||||
|
return Response.json({ error: "No challenge found" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = challenges.get(challengeKey)
|
||||||
|
if (!stored || stored.expires < Date.now()) {
|
||||||
|
challenges.delete(challengeKey!)
|
||||||
|
return Response.json({ error: "Challenge expired" }, { status: 400 })
|
||||||
|
}
|
||||||
|
challenges.delete(challengeKey)
|
||||||
|
|
||||||
|
const rpID = getRpId(req)
|
||||||
|
const origin = getOrigin(req)
|
||||||
|
|
||||||
|
let verification
|
||||||
|
try {
|
||||||
|
verification = await verifyRegistrationResponse({
|
||||||
|
response: body,
|
||||||
|
expectedChallenge: stored.challenge,
|
||||||
|
expectedOrigin: origin,
|
||||||
|
expectedRPID: rpID,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
return Response.json({ error: `Verification failed: ${err}` }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verification.verified || !verification.registrationInfo) {
|
||||||
|
return Response.json({ error: "Verification failed" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { credential } = verification.registrationInfo
|
||||||
|
await storeCredential(
|
||||||
|
credential.id,
|
||||||
|
stored.userId!,
|
||||||
|
isoBase64URL.fromBuffer(credential.publicKey),
|
||||||
|
credential.counter,
|
||||||
|
credential.transports,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const token = await createSession(stored.userId!)
|
||||||
|
const user = await getUserById(stored.userId!)
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ verified: true, username: user?.username }), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Set-Cookie": [sessionCookie(token), clearChallengeCookie()].join(", "),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginOptions(req: Request): Promise<Response> {
|
||||||
|
const rpID = getRpId(req)
|
||||||
|
|
||||||
|
const options = await generateAuthenticationOptions({
|
||||||
|
rpID,
|
||||||
|
userVerification: "preferred",
|
||||||
|
// Empty allowCredentials = discoverable credentials (passkey prompt)
|
||||||
|
})
|
||||||
|
|
||||||
|
const challengeKey = crypto.randomUUID()
|
||||||
|
challenges.set(challengeKey, {
|
||||||
|
challenge: options.challenge,
|
||||||
|
expires: Date.now() + 120_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(options), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Set-Cookie": challengeCookie(challengeKey),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginVerify(req: Request): Promise<Response> {
|
||||||
|
const body = (await req.json()) as AuthenticationResponseJSON
|
||||||
|
const challengeKey = getCookie(req, "challenge_key")
|
||||||
|
if (!challengeKey) {
|
||||||
|
return Response.json({ error: "No challenge found" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = challenges.get(challengeKey)
|
||||||
|
if (!stored || stored.expires < Date.now()) {
|
||||||
|
challenges.delete(challengeKey!)
|
||||||
|
return Response.json({ error: "Challenge expired" }, { status: 400 })
|
||||||
|
}
|
||||||
|
challenges.delete(challengeKey)
|
||||||
|
|
||||||
|
// Look up credential
|
||||||
|
const credentialId = body.id
|
||||||
|
const credential = await getCredentialById(credentialId)
|
||||||
|
if (!credential) {
|
||||||
|
return Response.json({ error: "Unknown credential" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const rpID = getRpId(req)
|
||||||
|
const origin = getOrigin(req)
|
||||||
|
|
||||||
|
let verification
|
||||||
|
try {
|
||||||
|
verification = await verifyAuthenticationResponse({
|
||||||
|
response: body,
|
||||||
|
expectedChallenge: stored.challenge,
|
||||||
|
expectedOrigin: origin,
|
||||||
|
expectedRPID: rpID,
|
||||||
|
credential: {
|
||||||
|
id: credential.id,
|
||||||
|
publicKey: isoBase64URL.toBuffer(credential.public_key),
|
||||||
|
counter: credential.counter,
|
||||||
|
transports: credential.transports ? JSON.parse(credential.transports) : undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
return Response.json({ error: `Verification failed: ${err}` }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verification.verified) {
|
||||||
|
return Response.json({ error: "Verification failed" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update counter
|
||||||
|
await updateCredentialCounter(credentialId, verification.authenticationInfo.newCounter)
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const token = await createSession(credential.user_id)
|
||||||
|
const user = await getUserById(credential.user_id)
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ verified: true, username: user?.username }), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Set-Cookie": [sessionCookie(token), clearChallengeCookie()].join(", "),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function me(req: Request): Promise<Response> {
|
||||||
|
const token = getCookie(req, "session")
|
||||||
|
if (!token) {
|
||||||
|
return Response.json({ user: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getSession(token)
|
||||||
|
if (!session) {
|
||||||
|
return new Response(JSON.stringify({ user: null }), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Set-Cookie": sessionCookie("", 0), // clear expired cookie
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ user: { id: session.user_id, username: session.username } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(req: Request): Promise<Response> {
|
||||||
|
const token = getCookie(req, "session")
|
||||||
|
if (token) {
|
||||||
|
await deleteSession(token)
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({ ok: true }), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Set-Cookie": sessionCookie("", 0),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
122
server/db.ts
Normal file
122
server/db.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { SQL } from "bun"
|
||||||
|
|
||||||
|
const db = new SQL({
|
||||||
|
adapter: "sqlite",
|
||||||
|
filename: "./leo-typing.db",
|
||||||
|
create: true,
|
||||||
|
strict: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function initDb() {
|
||||||
|
await db`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
`
|
||||||
|
await db`
|
||||||
|
CREATE TABLE IF NOT EXISTS credentials (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
counter INTEGER NOT NULL DEFAULT 0,
|
||||||
|
transports TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
`
|
||||||
|
await db`
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- User queries ----
|
||||||
|
|
||||||
|
export async function createUser(username: string): Promise<{ id: number; username: string }> {
|
||||||
|
const [user] = await db`
|
||||||
|
INSERT INTO users (username) VALUES (${username})
|
||||||
|
RETURNING id, username
|
||||||
|
`
|
||||||
|
return user as { id: number; username: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserByUsername(username: string) {
|
||||||
|
const [user] = await db`SELECT id, username FROM users WHERE username = ${username}`
|
||||||
|
return user as { id: number; username: string } | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserById(id: number) {
|
||||||
|
const [user] = await db`SELECT id, username FROM users WHERE id = ${id}`
|
||||||
|
return user as { id: number; username: string } | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Credential queries ----
|
||||||
|
|
||||||
|
export async function storeCredential(
|
||||||
|
credentialId: string,
|
||||||
|
userId: number,
|
||||||
|
publicKey: string,
|
||||||
|
counter: number,
|
||||||
|
transports?: string[],
|
||||||
|
) {
|
||||||
|
await db`
|
||||||
|
INSERT INTO credentials (id, user_id, public_key, counter, transports)
|
||||||
|
VALUES (${credentialId}, ${userId}, ${publicKey}, ${counter}, ${transports ? JSON.stringify(transports) : null})
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCredentialById(credentialId: string) {
|
||||||
|
const [cred] = await db`SELECT * FROM credentials WHERE id = ${credentialId}`
|
||||||
|
return cred as {
|
||||||
|
id: string
|
||||||
|
user_id: number
|
||||||
|
public_key: string
|
||||||
|
counter: number
|
||||||
|
transports: string | null
|
||||||
|
} | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCredentialsByUserId(userId: number) {
|
||||||
|
const creds = await db`SELECT * FROM credentials WHERE user_id = ${userId}`
|
||||||
|
return creds as Array<{
|
||||||
|
id: string
|
||||||
|
user_id: number
|
||||||
|
public_key: string
|
||||||
|
counter: number
|
||||||
|
transports: string | null
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCredentialCounter(credentialId: string, counter: number) {
|
||||||
|
await db`UPDATE credentials SET counter = ${counter} WHERE id = ${credentialId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Session queries ----
|
||||||
|
|
||||||
|
export async function createSession(userId: number): Promise<string> {
|
||||||
|
const token = crypto.randomUUID()
|
||||||
|
const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()
|
||||||
|
await db`
|
||||||
|
INSERT INTO sessions (token, user_id, expires_at)
|
||||||
|
VALUES (${token}, ${userId}, ${expires})
|
||||||
|
`
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSession(token: string) {
|
||||||
|
const [session] = await db`
|
||||||
|
SELECT s.token, s.user_id, s.expires_at, u.username
|
||||||
|
FROM sessions s JOIN users u ON s.user_id = u.id
|
||||||
|
WHERE s.token = ${token} AND s.expires_at > datetime('now')
|
||||||
|
`
|
||||||
|
return session as { token: string; user_id: number; username: string; expires_at: string } | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSession(token: string) {
|
||||||
|
await db`DELETE FROM sessions WHERE token = ${token}`
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,12 @@ import type { SessionResult } from './types'
|
||||||
|
|
||||||
type Tab = 'lessons' | 'free' | 'game' | 'stats'
|
type Tab = 'lessons' | 'free' | 'game' | 'stats'
|
||||||
|
|
||||||
export default function App() {
|
type AppProps = {
|
||||||
|
user: { id: number; username: string }
|
||||||
|
onLogout: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App({ user, onLogout }: AppProps) {
|
||||||
const [tab, setTab] = useState<Tab>('lessons')
|
const [tab, setTab] = useState<Tab>('lessons')
|
||||||
const { progress, completeLesson, addSession, updateHighScore } = useStats()
|
const { progress, completeLesson, addSession, updateHighScore } = useStats()
|
||||||
|
|
||||||
|
|
@ -58,7 +63,7 @@ export default function App() {
|
||||||
}, [updateHighScore, addSession])
|
}, [updateHighScore, addSession])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout activeTab={tab} onTabChange={setTab}>
|
<Layout activeTab={tab} onTabChange={setTab} username={user.username} onLogout={onLogout}>
|
||||||
{tab === 'lessons' && (
|
{tab === 'lessons' && (
|
||||||
<LessonMode progress={progress} onComplete={handleLessonComplete} />
|
<LessonMode progress={progress} onComplete={handleLessonComplete} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
180
src/components/AuthGate.tsx
Normal file
180
src/components/AuthGate.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
import { useState, useEffect, type ReactNode } from 'react'
|
||||||
|
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
|
||||||
|
import '../styles/auth.css'
|
||||||
|
|
||||||
|
type User = { id: number; username: string }
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: (user: User, onLogout: () => void) => ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthGate({ children }: Props) {
|
||||||
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [tab, setTab] = useState<'register' | 'login'>('login')
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
// Check existing session
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/auth/me')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.user) setUser(data.user)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleRegister = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setBusy(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const optRes = await fetch('/api/auth/register/options', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: username.trim() }),
|
||||||
|
})
|
||||||
|
if (!optRes.ok) {
|
||||||
|
const err = await optRes.json()
|
||||||
|
throw new Error(err.error || 'Failed to start registration')
|
||||||
|
}
|
||||||
|
const options = await optRes.json()
|
||||||
|
const credential = await startRegistration({ optionsJSON: options })
|
||||||
|
const verRes = await fetch('/api/auth/register/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(credential),
|
||||||
|
})
|
||||||
|
if (!verRes.ok) {
|
||||||
|
const err = await verRes.json()
|
||||||
|
throw new Error(err.error || 'Registration failed')
|
||||||
|
}
|
||||||
|
const result = await verRes.json()
|
||||||
|
setUser({ id: 0, username: result.username })
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
if (err.name === 'NotAllowedError') {
|
||||||
|
setError('Passkey creation was cancelled')
|
||||||
|
} else {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
setError('')
|
||||||
|
setBusy(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const optRes = await fetch('/api/auth/login/options', { method: 'POST' })
|
||||||
|
if (!optRes.ok) throw new Error('Failed to start login')
|
||||||
|
const options = await optRes.json()
|
||||||
|
const credential = await startAuthentication({ optionsJSON: options })
|
||||||
|
const verRes = await fetch('/api/auth/login/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(credential),
|
||||||
|
})
|
||||||
|
if (!verRes.ok) {
|
||||||
|
const err = await verRes.json()
|
||||||
|
throw new Error(err.error || 'Login failed')
|
||||||
|
}
|
||||||
|
const result = await verRes.json()
|
||||||
|
setUser({ id: 0, username: result.username })
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
if (err.name === 'NotAllowedError') {
|
||||||
|
setError('Passkey authentication was cancelled')
|
||||||
|
} else {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST' })
|
||||||
|
setUser(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="auth-container">
|
||||||
|
<div className="auth-card">
|
||||||
|
<div className="auth-passkey">⌨️</div>
|
||||||
|
<div className="auth-title">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
return <>{children(user, handleLogout)}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-container">
|
||||||
|
<div className="auth-card">
|
||||||
|
<div className="auth-passkey">⌨️</div>
|
||||||
|
<div className="auth-title">Leo's Typing Tutor</div>
|
||||||
|
<div className="auth-subtitle">Sign in with a passkey to track your progress</div>
|
||||||
|
|
||||||
|
<div className="auth-tabs">
|
||||||
|
<button
|
||||||
|
className={`auth-tab ${tab === 'login' ? 'auth-tabActive' : ''}`}
|
||||||
|
onClick={() => { setTab('login'); setError('') }}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`auth-tab ${tab === 'register' ? 'auth-tabActive' : ''}`}
|
||||||
|
onClick={() => { setTab('register'); setError('') }}
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'register' ? (
|
||||||
|
<form className="auth-form" onSubmit={handleRegister}>
|
||||||
|
<input
|
||||||
|
className="auth-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Pick a username"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
maxLength={32}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button className="auth-btn" type="submit" disabled={busy || !username.trim()}>
|
||||||
|
{busy ? 'Creating passkey...' : 'Create Account'}
|
||||||
|
</button>
|
||||||
|
<div className="auth-hint">
|
||||||
|
You'll be asked to create a passkey using your device's fingerprint, face, or PIN.
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="auth-form">
|
||||||
|
<button className="auth-btn" onClick={handleLogin} disabled={busy}>
|
||||||
|
{busy ? 'Authenticating...' : 'Sign in with Passkey'}
|
||||||
|
</button>
|
||||||
|
<div className="auth-hint">
|
||||||
|
Your device will show your saved passkeys. Pick yours to sign in.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="auth-error">{error}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import styles from '../styles/game.module.css'
|
import '../styles/game.css'
|
||||||
import selectorStyles from '../styles/missile.module.css'
|
import '../styles/missile.css'
|
||||||
import { playKeyClick, playWordComplete, playCombo, playMiss, playGameOver } from '../sounds'
|
import { playKeyClick, playWordComplete, playCombo, playMiss, playGameOver } from '../sounds'
|
||||||
import { MissileGame } from './MissileGame'
|
import { MissileGame } from './MissileGame'
|
||||||
|
|
||||||
|
|
@ -22,7 +22,7 @@ type FallingWord = {
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
speed: number
|
speed: number
|
||||||
matched: number // how many chars matched
|
matched: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type Explosion = {
|
type Explosion = {
|
||||||
|
|
@ -100,7 +100,7 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
|
||||||
const word: FallingWord = {
|
const word: FallingWord = {
|
||||||
id: nextIdRef.current++,
|
id: nextIdRef.current++,
|
||||||
text,
|
text,
|
||||||
x: Math.random() * 70 + 5, // 5%-75% from left
|
x: Math.random() * 70 + 5,
|
||||||
y: -5,
|
y: -5,
|
||||||
speed: 0.2 + difficulty * 0.05,
|
speed: 0.2 + difficulty * 0.05,
|
||||||
matched: 0,
|
matched: 0,
|
||||||
|
|
@ -124,7 +124,6 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
|
||||||
containerRef.current?.focus()
|
containerRef.current?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Game loop
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!started || gameOver) return
|
if (!started || gameOver) return
|
||||||
|
|
||||||
|
|
@ -132,17 +131,15 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
|
||||||
|
|
||||||
const tick = (now: number) => {
|
const tick = (now: number) => {
|
||||||
if (gameOverRef.current) return
|
if (gameOverRef.current) return
|
||||||
const dt = (now - lastTime) / 16.67 // normalize to ~60fps
|
const dt = (now - lastTime) / 16.67
|
||||||
lastTime = now
|
lastTime = now
|
||||||
|
|
||||||
// Spawn
|
|
||||||
const spawnInterval = Math.max(2200 - difficultyRef.current * 80, 900)
|
const spawnInterval = Math.max(2200 - difficultyRef.current * 80, 900)
|
||||||
if (now - lastSpawnRef.current > spawnInterval) {
|
if (now - lastSpawnRef.current > spawnInterval) {
|
||||||
spawnWord()
|
spawnWord()
|
||||||
lastSpawnRef.current = now
|
lastSpawnRef.current = now
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move words
|
|
||||||
setWords(prev => {
|
setWords(prev => {
|
||||||
const next: FallingWord[] = []
|
const next: FallingWord[] = []
|
||||||
let lostLife = false
|
let lostLife = false
|
||||||
|
|
@ -172,7 +169,6 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
// Increase difficulty
|
|
||||||
difficultyRef.current = 1 + score / 100
|
difficultyRef.current = 1 + score / 100
|
||||||
|
|
||||||
frameRef.current = requestAnimationFrame(tick)
|
frameRef.current = requestAnimationFrame(tick)
|
||||||
|
|
@ -182,7 +178,6 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
|
||||||
return () => cancelAnimationFrame(frameRef.current)
|
return () => cancelAnimationFrame(frameRef.current)
|
||||||
}, [started, gameOver, spawnWord, score])
|
}, [started, gameOver, spawnWord, score])
|
||||||
|
|
||||||
// Fire onGameOver
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gameOver && started) {
|
if (gameOver && started) {
|
||||||
playGameOver()
|
playGameOver()
|
||||||
|
|
@ -224,7 +219,6 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
|
||||||
|
|
||||||
const newInput = inputRef.current + e.key
|
const newInput = inputRef.current + e.key
|
||||||
|
|
||||||
// Do matching + removal in one atomic setWords call
|
|
||||||
setWords(prev => {
|
setWords(prev => {
|
||||||
const completed = prev.find(w => w.text === newInput)
|
const completed = prev.find(w => w.text === newInput)
|
||||||
if (completed) {
|
if (completed) {
|
||||||
|
|
@ -233,7 +227,6 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
|
||||||
updateInput('')
|
updateInput('')
|
||||||
playWordComplete()
|
playWordComplete()
|
||||||
|
|
||||||
// Spawn explosion at word position
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
setExplosions(ex => [...ex, {
|
setExplosions(ex => [...ex, {
|
||||||
id: now,
|
id: now,
|
||||||
|
|
@ -242,11 +235,8 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
|
||||||
y: completed.y,
|
y: completed.y,
|
||||||
points,
|
points,
|
||||||
}])
|
}])
|
||||||
// Spawn particles
|
|
||||||
setParticles(p => [...p, ...spawnParticles(completed.x, completed.y, 12)])
|
setParticles(p => [...p, ...spawnParticles(completed.x, completed.y, 12)])
|
||||||
// Screen flash
|
|
||||||
setFlash(now)
|
setFlash(now)
|
||||||
// Combo tracking
|
|
||||||
setCombo(c => {
|
setCombo(c => {
|
||||||
const next = c + 1
|
const next = c + 1
|
||||||
if (next >= 3) {
|
if (next >= 3) {
|
||||||
|
|
@ -258,7 +248,6 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
|
||||||
clearTimeout(comboTimerRef.current)
|
clearTimeout(comboTimerRef.current)
|
||||||
comboTimerRef.current = setTimeout(() => setCombo(0), 2000)
|
comboTimerRef.current = setTimeout(() => setCombo(0), 2000)
|
||||||
|
|
||||||
// Auto-clean effects after animation
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setExplosions(ex => ex.filter(e => e.id !== now))
|
setExplosions(ex => ex.filter(e => e.id !== now))
|
||||||
}, 500)
|
}, 500)
|
||||||
|
|
@ -268,7 +257,6 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
|
||||||
|
|
||||||
return prev.filter(w => w.id !== completed.id)
|
return prev.filter(w => w.id !== completed.id)
|
||||||
}
|
}
|
||||||
// No completion — update partial matches
|
|
||||||
updateInput(newInput)
|
updateInput(newInput)
|
||||||
return prev.map(w => ({
|
return prev.map(w => ({
|
||||||
...w,
|
...w,
|
||||||
|
|
@ -291,20 +279,20 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={`${styles.gameContainer} ${!focused ? styles.blurred : ''}`}
|
className={`gameContainer ${!focused ? 'blurred' : ''}`}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={() => setFocused(true)}
|
onFocus={() => setFocused(true)}
|
||||||
onBlur={() => setFocused(false)}
|
onBlur={() => setFocused(false)}
|
||||||
>
|
>
|
||||||
<div className={styles.gameHud}>
|
<div className="gameHud">
|
||||||
<div className={styles.hudItem}>
|
<div className="hudItem">
|
||||||
<span className={styles.hudLabel}>Score</span>
|
<span className="hudLabel">Score</span>
|
||||||
<span className={styles.hudValue}>{score}</span>
|
<span className="hudValue">{score}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.hudItem}>
|
<div className="hudItem">
|
||||||
<span className={styles.hudLabel}>Lives</span>
|
<span className="hudLabel">Lives</span>
|
||||||
<span className={`${styles.hudValue} ${styles.lives}`}>
|
<span className="hudValue lives">
|
||||||
{'❤️'.repeat(Math.max(0, lives))}{'🖤'.repeat(Math.max(0, 5 - lives))}
|
{'❤️'.repeat(Math.max(0, lives))}{'🖤'.repeat(Math.max(0, 5 - lives))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -313,33 +301,31 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
|
||||||
{words.map(w => (
|
{words.map(w => (
|
||||||
<div
|
<div
|
||||||
key={w.id}
|
key={w.id}
|
||||||
className={`${styles.word} ${w.matched > 0 ? styles.wordMatched : ''}`}
|
className={`word ${w.matched > 0 ? 'wordMatched' : ''}`}
|
||||||
style={{ left: `${w.x}%`, top: `${w.y}%` }}
|
style={{ left: `${w.x}%`, top: `${w.y}%` }}
|
||||||
>
|
>
|
||||||
{w.text.split('').map((ch, i) => (
|
{w.text.split('').map((ch, i) => (
|
||||||
<span key={i} className={i < w.matched ? styles.wordMatchedChar : ''}>
|
<span key={i} className={i < w.matched ? 'wordMatchedChar' : ''}>
|
||||||
{ch}
|
{ch}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Exploding words */}
|
|
||||||
{explosions.map(ex => (
|
{explosions.map(ex => (
|
||||||
<div
|
<div
|
||||||
key={ex.id}
|
key={ex.id}
|
||||||
className={`${styles.word} ${styles.wordExploding}`}
|
className="word wordExploding"
|
||||||
style={{ left: `${ex.x}%`, top: `${ex.y}%` }}
|
style={{ left: `${ex.x}%`, top: `${ex.y}%` }}
|
||||||
>
|
>
|
||||||
{ex.text}
|
{ex.text}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Particles */}
|
|
||||||
{particles.map(p => (
|
{particles.map(p => (
|
||||||
<div
|
<div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className={styles.particle}
|
className="particle"
|
||||||
style={{
|
style={{
|
||||||
left: `${p.x}%`,
|
left: `${p.x}%`,
|
||||||
top: `${p.y}%`,
|
top: `${p.y}%`,
|
||||||
|
|
@ -352,42 +338,39 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Score popups */}
|
|
||||||
{explosions.map(ex => (
|
{explosions.map(ex => (
|
||||||
<div
|
<div
|
||||||
key={`score-${ex.id}`}
|
key={`score-${ex.id}`}
|
||||||
className={styles.scorePopup}
|
className="scorePopup"
|
||||||
style={{ left: `${ex.x + 3}%`, top: `${ex.y - 2}%` }}
|
style={{ left: `${ex.x + 3}%`, top: `${ex.y - 2}%` }}
|
||||||
>
|
>
|
||||||
+{ex.points}
|
+{ex.points}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Screen flash */}
|
{flash > 0 && <div key={flash} className="screenFlash" />}
|
||||||
{flash > 0 && <div key={flash} className={styles.screenFlash} />}
|
|
||||||
|
|
||||||
{/* Combo text */}
|
|
||||||
{showCombo > 0 && combo >= 3 && (
|
{showCombo > 0 && combo >= 3 && (
|
||||||
<div key={showCombo} className={styles.comboText}>
|
<div key={showCombo} className="comboText">
|
||||||
{combo}x COMBO!
|
{combo}x COMBO!
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.dangerZone} />
|
<div className="dangerZone" />
|
||||||
|
|
||||||
{started && !gameOver && (
|
{started && !gameOver && (
|
||||||
<div className={styles.inputDisplay}>
|
<div className="inputDisplay">
|
||||||
{input || <span style={{ opacity: 0.3 }}>type a word...</span>}
|
{input || <span style={{ opacity: 0.3 }}>type a word...</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!started && (
|
{!started && (
|
||||||
<div className={styles.gameOver}>
|
<div className="gameOver">
|
||||||
<div className={styles.gameOverTitle}>Falling Words</div>
|
<div className="gameOverTitle">Falling Words</div>
|
||||||
<div className={styles.gameOverScore}>
|
<div className="gameOverScore">
|
||||||
Type the words before they fall!
|
Type the words before they fall!
|
||||||
</div>
|
</div>
|
||||||
<button className={styles.gameOverButtons} onClick={startGame}
|
<button className="gameOverButtons" onClick={startGame}
|
||||||
style={{ background: 'var(--accent)', color: '#fff', fontWeight: 600, fontSize: 18, padding: '14px 36px' }}>
|
style={{ background: 'var(--accent)', color: '#fff', fontWeight: 600, fontSize: 18, padding: '14px 36px' }}>
|
||||||
Start Game
|
Start Game
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -395,13 +378,13 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{gameOver && (
|
{gameOver && (
|
||||||
<div className={styles.gameOver}>
|
<div className="gameOver">
|
||||||
<div className={styles.gameOverTitle}>Game Over!</div>
|
<div className="gameOverTitle">Game Over!</div>
|
||||||
<div className={styles.gameOverScore}>Score: {score}</div>
|
<div className="gameOverScore">Score: {score}</div>
|
||||||
{score > highScore && score > 0 && (
|
{score > highScore && score > 0 && (
|
||||||
<div className={styles.highScore}>New High Score!</div>
|
<div className="highScore">New High Score!</div>
|
||||||
)}
|
)}
|
||||||
<div className={styles.gameOverButtons}>
|
<div className="gameOverButtons">
|
||||||
<button
|
<button
|
||||||
onClick={startGame}
|
onClick={startGame}
|
||||||
style={{ background: 'var(--accent)', color: '#fff', fontWeight: 600, fontSize: 16, padding: '12px 28px' }}
|
style={{ background: 'var(--accent)', color: '#fff', fontWeight: 600, fontSize: 16, padding: '12px 28px' }}
|
||||||
|
|
@ -453,18 +436,18 @@ export function GameMode({ highScore, onGameOver }: Props) {
|
||||||
<p style={{ color: 'var(--text-dim)', fontSize: 14, marginBottom: 24 }}>
|
<p style={{ color: 'var(--text-dim)', fontSize: 14, marginBottom: 24 }}>
|
||||||
High Score: {highScore}
|
High Score: {highScore}
|
||||||
</p>
|
</p>
|
||||||
<div className={selectorStyles.selector}>
|
<div className="selector">
|
||||||
<div className={selectorStyles.selectorCard} onClick={() => setChoice('falling')}>
|
<div className="selectorCard" onClick={() => setChoice('falling')}>
|
||||||
<div className={selectorStyles.selectorIcon}>🌧️</div>
|
<div className="selectorIcon">🌧️</div>
|
||||||
<div className={selectorStyles.selectorTitle}>Falling Words</div>
|
<div className="selectorTitle">Falling Words</div>
|
||||||
<div className={selectorStyles.selectorDesc}>
|
<div className="selectorDesc">
|
||||||
Type falling words before they hit the ground. Build combos for bonus points!
|
Type falling words before they hit the ground. Build combos for bonus points!
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={selectorStyles.selectorCard} onClick={() => setChoice('missile')}>
|
<div className="selectorCard" onClick={() => setChoice('missile')}>
|
||||||
<div className={selectorStyles.selectorIcon}>🚀</div>
|
<div className="selectorIcon">🚀</div>
|
||||||
<div className={selectorStyles.selectorTitle}>Missile Strike</div>
|
<div className="selectorTitle">Missile Strike</div>
|
||||||
<div className={selectorStyles.selectorDesc}>
|
<div className="selectorDesc">
|
||||||
Launch missiles at the enemy city. Type words to dodge interceptors!
|
Launch missiles at the enemy city. Type words to dodge interceptors!
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { KEYBOARD_LAYOUT, FINGER_COLORS, FINGER_LABELS } from '../data/keyboard'
|
import { KEYBOARD_LAYOUT, FINGER_COLORS, FINGER_LABELS } from '../data/keyboard'
|
||||||
import type { KeyInfo } from '../types'
|
import type { KeyInfo } from '../types'
|
||||||
import styles from '../styles/keyboard.module.css'
|
import '../styles/keyboard.css'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
activeKey?: string
|
activeKey?: string
|
||||||
|
|
@ -39,9 +39,9 @@ export function Keyboard({ activeKey, keyStats }: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.keyboard}>
|
<div className="keyboard">
|
||||||
{KEYBOARD_LAYOUT.map((row, ri) => (
|
{KEYBOARD_LAYOUT.map((row, ri) => (
|
||||||
<div className={styles.row} key={ri}>
|
<div className="row" key={ri}>
|
||||||
{row.map(ki => {
|
{row.map(ki => {
|
||||||
const isActive = activeKey !== undefined &&
|
const isActive = activeKey !== undefined &&
|
||||||
ki.key === activeKey.toLowerCase()
|
ki.key === activeKey.toLowerCase()
|
||||||
|
|
@ -51,14 +51,14 @@ export function Keyboard({ activeKey, keyStats }: Props) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={ki.key}
|
key={ki.key}
|
||||||
className={`${styles.key} ${ki.key === ' ' ? styles.space : ''} ${isActive ? styles.active : ''} ${isPressed ? styles.pressed : ''}`}
|
className={`key ${ki.key === ' ' ? 'space' : ''} ${isActive ? 'active' : ''} ${isPressed ? 'pressed' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
background: getKeyBg(ki),
|
background: getKeyBg(ki),
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
<span className={styles.fingerLabel}>{FINGER_LABELS[ki.finger]}</span>
|
<span className="fingerLabel">{FINGER_LABELS[ki.finger]}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ type Tab = 'lessons' | 'free' | 'game' | 'stats'
|
||||||
type Props = {
|
type Props = {
|
||||||
activeTab: Tab
|
activeTab: Tab
|
||||||
onTabChange: (tab: Tab) => void
|
onTabChange: (tab: Tab) => void
|
||||||
|
username: string
|
||||||
|
onLogout: () => void
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -15,7 +17,7 @@ const TABS: { id: Tab; label: string; icon: string }[] = [
|
||||||
{ id: 'stats', label: 'Stats', icon: '📊' },
|
{ id: 'stats', label: 'Stats', icon: '📊' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function Layout({ activeTab, onTabChange, children }: Props) {
|
export function Layout({ activeTab, onTabChange, username, onLogout, children }: Props) {
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 960, margin: '0 auto', padding: '20px 24px' }}>
|
<div style={{ maxWidth: 960, margin: '0 auto', padding: '20px 24px' }}>
|
||||||
<header style={{
|
<header style={{
|
||||||
|
|
@ -33,24 +35,41 @@ export function Layout({ activeTab, onTabChange, children }: Props) {
|
||||||
}}>
|
}}>
|
||||||
Leo's Typing Tutor
|
Leo's Typing Tutor
|
||||||
</h1>
|
</h1>
|
||||||
<nav style={{ display: 'flex', gap: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
{TABS.map(tab => (
|
<nav style={{ display: 'flex', gap: 4 }}>
|
||||||
|
{TABS.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => onTabChange(tab.id)}
|
||||||
|
style={{
|
||||||
|
background: activeTab === tab.id ? 'var(--accent)' : 'var(--bg-card)',
|
||||||
|
color: activeTab === tab.id ? '#fff' : 'var(--text)',
|
||||||
|
fontWeight: activeTab === tab.id ? 600 : 400,
|
||||||
|
padding: '10px 18px',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.icon} {tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 8 }}>
|
||||||
|
<span style={{ color: 'var(--text-dim)', fontSize: 13 }}>{username}</span>
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
onClick={onLogout}
|
||||||
onClick={() => onTabChange(tab.id)}
|
|
||||||
style={{
|
style={{
|
||||||
background: activeTab === tab.id ? 'var(--accent)' : 'var(--bg-card)',
|
background: 'var(--bg-card)',
|
||||||
color: activeTab === tab.id ? '#fff' : 'var(--text)',
|
color: 'var(--text-dim)',
|
||||||
fontWeight: activeTab === tab.id ? 600 : 400,
|
fontSize: 12,
|
||||||
padding: '10px 18px',
|
padding: '6px 12px',
|
||||||
borderRadius: 'var(--radius)',
|
borderRadius: 'var(--radius)',
|
||||||
fontSize: 14,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tab.icon} {tab.label}
|
Logout
|
||||||
</button>
|
</button>
|
||||||
))}
|
</div>
|
||||||
</nav>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main>{children}</main>
|
<main>{children}</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { LESSONS } from '../data/lessons'
|
import { LESSONS } from '../data/lessons'
|
||||||
import type { UserProgress } from '../types'
|
import type { UserProgress } from '../types'
|
||||||
import styles from '../styles/typing.module.css'
|
import '../styles/typing.css'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
progress: UserProgress
|
progress: UserProgress
|
||||||
|
|
@ -16,13 +16,13 @@ export function LessonSelect({ progress, onSelect }: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.modeHeader}>
|
<div className="modeHeader">
|
||||||
<h2 className={styles.modeTitle}>Lessons</h2>
|
<h2 className="modeTitle">Lessons</h2>
|
||||||
<p className={styles.modeSubtitle}>
|
<p className="modeSubtitle">
|
||||||
Master each lesson to unlock the next ({progress.completedLessons.length}/{LESSONS.length} completed)
|
Master each lesson to unlock the next ({progress.completedLessons.length}/{LESSONS.length} completed)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.lessonSelect}>
|
<div className="lessonSelect">
|
||||||
{LESSONS.map(lesson => {
|
{LESSONS.map(lesson => {
|
||||||
const unlocked = isUnlocked(lesson.id)
|
const unlocked = isUnlocked(lesson.id)
|
||||||
const completed = progress.completedLessons.includes(lesson.id)
|
const completed = progress.completedLessons.includes(lesson.id)
|
||||||
|
|
@ -30,17 +30,17 @@ export function LessonSelect({ progress, onSelect }: Props) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={lesson.id}
|
key={lesson.id}
|
||||||
className={`${styles.lessonCard} ${!unlocked ? styles.locked : ''} ${completed ? styles.completed : ''}`}
|
className={`lessonCard ${!unlocked ? 'locked' : ''} ${completed ? 'completed' : ''}`}
|
||||||
onClick={() => unlocked && onSelect(lesson.id)}
|
onClick={() => unlocked && onSelect(lesson.id)}
|
||||||
>
|
>
|
||||||
<div className={styles.lessonId}>Lesson {lesson.id}</div>
|
<div className="lessonId">Lesson {lesson.id}</div>
|
||||||
<div className={styles.lessonName}>{lesson.name}</div>
|
<div className="lessonName">{lesson.name}</div>
|
||||||
{lesson.newKeys.length > 0 && (
|
{lesson.newKeys.length > 0 && (
|
||||||
<div className={styles.lessonKeys}>
|
<div className="lessonKeys">
|
||||||
New: {lesson.newKeys.join(' ')}
|
New: {lesson.newKeys.join(' ')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={styles.lessonStatus}>
|
<div className="lessonStatus">
|
||||||
{completed ? 'Completed' : unlocked ? 'Ready' : 'Locked'}
|
{completed ? 'Completed' : unlocked ? 'Ready' : 'Locked'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import styles from '../styles/missile.module.css'
|
import '../styles/missile.css'
|
||||||
import { playKeyClick, playLaunch, playDodge, playMissileHit, playExplosion, playGameOver } from '../sounds'
|
import { playKeyClick, playLaunch, playDodge, playMissileHit, playExplosion, playGameOver } from '../sounds'
|
||||||
import { LESSONS } from '../data/lessons'
|
import { LESSONS } from '../data/lessons'
|
||||||
|
|
||||||
|
|
@ -827,7 +827,7 @@ export function MissileGame({ highScore, onGameOver }: Props) {
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={wrapperRef}
|
ref={wrapperRef}
|
||||||
className={`${styles.wrapper} ${!focused ? styles.blurred : ''} ${shaking ? styles.shake : ''}`}
|
className={`wrapper ${!focused ? 'blurred' : ''} ${shaking ? 'shake' : ''}`}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={() => setFocused(true)}
|
onFocus={() => setFocused(true)}
|
||||||
|
|
@ -835,33 +835,33 @@ export function MissileGame({ highScore, onGameOver }: Props) {
|
||||||
>
|
>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className={styles.canvas}
|
className="canvas"
|
||||||
width={CANVAS_W}
|
width={CANVAS_W}
|
||||||
height={CANVAS_H}
|
height={CANVAS_H}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* HUD overlay */}
|
{/* HUD overlay */}
|
||||||
{started && !gameOver && (
|
{started && !gameOver && (
|
||||||
<div className={styles.hud}>
|
<div className="hud">
|
||||||
<div className={styles.hudLeft}>
|
<div className="hudLeft">
|
||||||
<span className={styles.hudLabel}>Score</span>
|
<span className="hudLabel">Score</span>
|
||||||
<span className={styles.hudValue}>{hudScore}</span>
|
<span className="hudValue">{hudScore}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
|
||||||
<span className={styles.hudLabel}>Missiles</span>
|
<span className="hudLabel">Missiles</span>
|
||||||
<div className={styles.missiles}>
|
<div className="missiles">
|
||||||
{[0, 1, 2].map(i => (
|
{[0, 1, 2].map(i => (
|
||||||
<span key={i} className={`${styles.missilePip} ${i < hudMissilesLeft ? styles.active : ''}`}>
|
<span key={i} className={`missilePip ${i < hudMissilesLeft ? 'active' : ''}`}>
|
||||||
🚀
|
🚀
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.hudRight}>
|
<div className="hudRight">
|
||||||
<span className={styles.hudLabel}>HP</span>
|
<span className="hudLabel">HP</span>
|
||||||
<div className={styles.hpPips}>
|
<div className="hpPips">
|
||||||
{[0, 1, 2].map(i => (
|
{[0, 1, 2].map(i => (
|
||||||
<div key={i} className={`${styles.hpPip} ${i < hudHp ? styles.filled : ''}`} />
|
<div key={i} className={`hpPip ${i < hudHp ? 'filled' : ''}`} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -870,11 +870,11 @@ export function MissileGame({ highScore, onGameOver }: Props) {
|
||||||
|
|
||||||
{/* Word prompt */}
|
{/* Word prompt */}
|
||||||
{started && !gameOver && hudWord && hudPhase === 'dodging' && (
|
{started && !gameOver && hudWord && hudPhase === 'dodging' && (
|
||||||
<div className={styles.wordPrompt}>
|
<div className="wordPrompt">
|
||||||
{hudWord.split('').map((ch, i) => (
|
{hudWord.split('').map((ch, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
className={i < hudTypedIndex ? styles.typedChar : i === hudTypedIndex ? styles.cursor : ''}
|
className={i < hudTypedIndex ? 'typedChar' : i === hudTypedIndex ? 'cursor' : ''}
|
||||||
>
|
>
|
||||||
{ch}
|
{ch}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -884,22 +884,22 @@ export function MissileGame({ highScore, onGameOver }: Props) {
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Progress bar */}
|
||||||
{started && !gameOver && (
|
{started && !gameOver && (
|
||||||
<div className={styles.progressWrap}>
|
<div className="progressWrap">
|
||||||
<div className={styles.progressFill} style={{ width: `${hudProgress * 100}%` }} />
|
<div className="progressFill" style={{ width: `${hudProgress * 100}%` }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Ready screen */}
|
{/* Ready screen */}
|
||||||
{!started && (
|
{!started && (
|
||||||
<div className={styles.overlay}>
|
<div className="overlay">
|
||||||
<div className={styles.overlayTitle}>Missile Strike</div>
|
<div className="overlayTitle">Missile Strike</div>
|
||||||
<div className={styles.overlaySubtitle}>
|
<div className="overlaySubtitle">
|
||||||
Type words to dodge interceptors and strike the city!
|
Type words to dodge interceptors and strike the city!
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: 'var(--text-dim)', fontSize: 14, marginTop: 8, maxWidth: 400, textAlign: 'center', lineHeight: 1.6 }}>
|
<div style={{ color: 'var(--text-dim)', fontSize: 14, marginTop: 8, maxWidth: 400, textAlign: 'center', lineHeight: 1.6 }}>
|
||||||
3 missiles, 3 HP each. Type 4 words per missile to reach the target.
|
3 missiles, 3 HP each. Type 4 words per missile to reach the target.
|
||||||
</div>
|
</div>
|
||||||
<button className={styles.overlayBtn} onClick={startGame}>
|
<button className="overlayBtn" onClick={startGame}>
|
||||||
Launch!
|
Launch!
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -907,13 +907,13 @@ export function MissileGame({ highScore, onGameOver }: Props) {
|
||||||
|
|
||||||
{/* Game over */}
|
{/* Game over */}
|
||||||
{gameOver && (
|
{gameOver && (
|
||||||
<div className={styles.overlay}>
|
<div className="overlay">
|
||||||
<div className={styles.overlayTitle}>Mission Complete</div>
|
<div className="overlayTitle">Mission Complete</div>
|
||||||
<div className={styles.overlaySubtitle}>Score: {finalScore}</div>
|
<div className="overlaySubtitle">Score: {finalScore}</div>
|
||||||
{finalScore > highScore && finalScore > 0 && (
|
{finalScore > highScore && finalScore > 0 && (
|
||||||
<div className={styles.newHighScore}>New High Score!</div>
|
<div className="newHighScore">New High Score!</div>
|
||||||
)}
|
)}
|
||||||
<button className={styles.overlayBtn} onClick={startGame}>
|
<button className="overlayBtn" onClick={startGame}>
|
||||||
Play Again
|
Play Again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import styles from '../styles/typing.module.css'
|
import '../styles/typing.css'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
wpm: number
|
wpm: number
|
||||||
|
|
@ -10,23 +10,23 @@ type Props = {
|
||||||
|
|
||||||
export function ResultsModal({ wpm, accuracy, unlocked, onRetry, onBack }: Props) {
|
export function ResultsModal({ wpm, accuracy, unlocked, onRetry, onBack }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.modal} onClick={onBack}>
|
<div className="modal" onClick={onBack}>
|
||||||
<div className={styles.modalContent} onClick={e => e.stopPropagation()}>
|
<div className="modalContent" onClick={e => e.stopPropagation()}>
|
||||||
<div className={styles.modalTitle}>
|
<div className="modalTitle">
|
||||||
{accuracy >= 90 ? 'Great Job!' : 'Keep Practicing!'}
|
{accuracy >= 90 ? 'Great Job!' : 'Keep Practicing!'}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.modalStats}>
|
<div className="modalStats">
|
||||||
<div className={styles.stat}>
|
<div className="stat">
|
||||||
<span className={styles.statValue}>{wpm}</span>
|
<span className="statValue">{wpm}</span>
|
||||||
<span className={styles.statLabel}>WPM</span>
|
<span className="statLabel">WPM</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.stat}>
|
<div className="stat">
|
||||||
<span className={styles.statValue}>{accuracy}%</span>
|
<span className="statValue">{accuracy}%</span>
|
||||||
<span className={styles.statLabel}>Accuracy</span>
|
<span className="statLabel">Accuracy</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{unlocked && (
|
{unlocked && (
|
||||||
<div className={styles.unlockText}>
|
<div className="unlockText">
|
||||||
Unlocked: {unlocked}
|
Unlocked: {unlocked}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -35,9 +35,9 @@ export function ResultsModal({ wpm, accuracy, unlocked, onRetry, onBack }: Props
|
||||||
Need 90% accuracy to unlock the next lesson
|
Need 90% accuracy to unlock the next lesson
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={styles.modalButtons}>
|
<div className="modalButtons">
|
||||||
<button className={styles.secondaryBtn} onClick={onBack}>Back</button>
|
<button className="secondaryBtn" onClick={onBack}>Back</button>
|
||||||
<button className={styles.primaryBtn} onClick={onRetry}>Try Again</button>
|
<button className="primaryBtn" onClick={onRetry}>Try Again</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianG
|
||||||
import { LESSONS } from '../data/lessons'
|
import { LESSONS } from '../data/lessons'
|
||||||
import { KEYBOARD_LAYOUT, FINGER_COLORS } from '../data/keyboard'
|
import { KEYBOARD_LAYOUT, FINGER_COLORS } from '../data/keyboard'
|
||||||
import type { UserProgress } from '../types'
|
import type { UserProgress } from '../types'
|
||||||
import styles from '../styles/game.module.css'
|
import '../styles/game.css'
|
||||||
import kbStyles from '../styles/keyboard.module.css'
|
import '../styles/keyboard.css'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
progress: UserProgress
|
progress: UserProgress
|
||||||
|
|
@ -12,7 +12,6 @@ type Props = {
|
||||||
export function StatsView({ progress }: Props) {
|
export function StatsView({ progress }: Props) {
|
||||||
const { sessions, completedLessons, gameHighScore } = progress
|
const { sessions, completedLessons, gameHighScore } = progress
|
||||||
|
|
||||||
// Build chart data from sessions (last 50)
|
|
||||||
const recentSessions = sessions.slice(-50)
|
const recentSessions = sessions.slice(-50)
|
||||||
const chartData = recentSessions.map((s, i) => ({
|
const chartData = recentSessions.map((s, i) => ({
|
||||||
session: i + 1,
|
session: i + 1,
|
||||||
|
|
@ -20,7 +19,6 @@ export function StatsView({ progress }: Props) {
|
||||||
accuracy: s.accuracy,
|
accuracy: s.accuracy,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Aggregate key stats across all sessions
|
|
||||||
const aggKeyStats: Record<string, { hits: number; misses: number }> = {}
|
const aggKeyStats: Record<string, { hits: number; misses: number }> = {}
|
||||||
for (const s of sessions) {
|
for (const s of sessions) {
|
||||||
for (const [key, stat] of Object.entries(s.keyStats)) {
|
for (const [key, stat] of Object.entries(s.keyStats)) {
|
||||||
|
|
@ -43,10 +41,9 @@ export function StatsView({ progress }: Props) {
|
||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 20 }}>Stats & Progress</h2>
|
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 20 }}>Stats & Progress</h2>
|
||||||
|
|
||||||
<div className={styles.statsGrid}>
|
<div className="statsGrid">
|
||||||
{/* Summary */}
|
<div className="statsCard">
|
||||||
<div className={styles.statsCard}>
|
<div className="statsCardTitle">Overview</div>
|
||||||
<div className={styles.statsCardTitle}>Overview</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 28, fontWeight: 700, color: 'var(--accent)', fontFamily: 'var(--font-mono)' }}>{avgWpm}</div>
|
<div style={{ fontSize: 28, fontWeight: 700, color: 'var(--accent)', fontFamily: 'var(--font-mono)' }}>{avgWpm}</div>
|
||||||
|
|
@ -67,20 +64,18 @@ export function StatsView({ progress }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lesson Progress */}
|
<div className="statsCard">
|
||||||
<div className={styles.statsCard}>
|
<div className="statsCardTitle">Lesson Progress</div>
|
||||||
<div className={styles.statsCardTitle}>Lesson Progress</div>
|
|
||||||
<div style={{ fontSize: 28, fontWeight: 700, color: 'var(--accent)', marginBottom: 12, fontFamily: 'var(--font-mono)' }}>
|
<div style={{ fontSize: 28, fontWeight: 700, color: 'var(--accent)', marginBottom: 12, fontFamily: 'var(--font-mono)' }}>
|
||||||
{completedLessons.length} / {LESSONS.length}
|
{completedLessons.length} / {LESSONS.length}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.progressBar}>
|
<div className="progressBar">
|
||||||
<div className={styles.progressFill} style={{ width: `${lessonProgress}%` }} />
|
<div className="progressFill" style={{ width: `${lessonProgress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* WPM Chart */}
|
<div className="statsCard">
|
||||||
<div className={styles.statsCard}>
|
<div className="statsCardTitle">WPM Over Time</div>
|
||||||
<div className={styles.statsCardTitle}>WPM Over Time</div>
|
|
||||||
{chartData.length > 0 ? (
|
{chartData.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={200}>
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
|
|
@ -100,9 +95,8 @@ export function StatsView({ progress }: Props) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Accuracy Chart */}
|
<div className="statsCard">
|
||||||
<div className={styles.statsCard}>
|
<div className="statsCardTitle">Accuracy Over Time</div>
|
||||||
<div className={styles.statsCardTitle}>Accuracy Over Time</div>
|
|
||||||
{chartData.length > 0 ? (
|
{chartData.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={200}>
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
|
|
@ -122,12 +116,11 @@ export function StatsView({ progress }: Props) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Key Accuracy Heatmap */}
|
<div className="statsCard" style={{ gridColumn: '1 / -1' }}>
|
||||||
<div className={styles.statsCard} style={{ gridColumn: '1 / -1' }}>
|
<div className="statsCardTitle">Key Accuracy Heatmap</div>
|
||||||
<div className={styles.statsCardTitle}>Key Accuracy Heatmap</div>
|
<div className="keyboard">
|
||||||
<div className={kbStyles.keyboard}>
|
|
||||||
{KEYBOARD_LAYOUT.map((row, ri) => (
|
{KEYBOARD_LAYOUT.map((row, ri) => (
|
||||||
<div className={kbStyles.row} key={ri}>
|
<div className="row" key={ri}>
|
||||||
{row.map(ki => {
|
{row.map(ki => {
|
||||||
const stat = aggKeyStats[ki.key]
|
const stat = aggKeyStats[ki.key]
|
||||||
let bg = FINGER_COLORS[ki.finger]
|
let bg = FINGER_COLORS[ki.finger]
|
||||||
|
|
@ -146,7 +139,7 @@ export function StatsView({ progress }: Props) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={ki.key}
|
key={ki.key}
|
||||||
className={`${kbStyles.key} ${ki.key === ' ' ? kbStyles.space : ''}`}
|
className={`key ${ki.key === ' ' ? 'space' : ''}`}
|
||||||
style={{ background: bg, opacity, color: '#fff' }}
|
style={{ background: bg, opacity, color: '#fff' }}
|
||||||
title={stat ? `${ki.key}: ${Math.round((stat.hits / (stat.hits + stat.misses)) * 100)}% accuracy` : `${ki.key}: no data`}
|
title={stat ? `${ki.key}: ${Math.round((stat.hits / (stat.hits + stat.misses)) * 100)}% accuracy` : `${ki.key}: no data`}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useRef, useState, useEffect } from 'react'
|
import { useRef, useState, useEffect } from 'react'
|
||||||
import styles from '../styles/typing.module.css'
|
import '../styles/typing.css'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
text: string
|
text: string
|
||||||
|
|
@ -20,33 +20,33 @@ export function TypingArea({ text, currentIndex, errors, wpm, accuracy, onKeyDow
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.stats}>
|
<div className="stats">
|
||||||
<div className={styles.stat}>
|
<div className="stat">
|
||||||
<span className={styles.statValue}>{wpm}</span>
|
<span className="statValue">{wpm}</span>
|
||||||
<span className={styles.statLabel}>WPM</span>
|
<span className="statLabel">WPM</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.stat}>
|
<div className="stat">
|
||||||
<span className={styles.statValue}>{accuracy}%</span>
|
<span className="statValue">{accuracy}%</span>
|
||||||
<span className={styles.statLabel}>Accuracy</span>
|
<span className="statLabel">Accuracy</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={`${styles.typingArea} ${!focused ? styles.blurred : ''}`}
|
className={`typingArea ${!focused ? 'blurred' : ''}`}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onFocus={() => setFocused(true)}
|
onFocus={() => setFocused(true)}
|
||||||
onBlur={() => setFocused(false)}
|
onBlur={() => setFocused(false)}
|
||||||
>
|
>
|
||||||
{text.split('').map((char, i) => {
|
{text.split('').map((char, i) => {
|
||||||
let cls = styles.pending
|
let cls = 'pending'
|
||||||
if (i < currentIndex) {
|
if (i < currentIndex) {
|
||||||
cls = errors.has(i) ? styles.incorrect : styles.correct
|
cls = errors.has(i) ? 'incorrect' : 'correct'
|
||||||
} else if (i === currentIndex) {
|
} else if (i === currentIndex) {
|
||||||
cls = styles.current
|
cls = 'current'
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span key={i} className={`${styles.char} ${cls}`}>
|
<span key={i} className={`char ${cls}`}>
|
||||||
{char}
|
{char}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
1
src/env.d.ts
vendored
Normal file
1
src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
declare module '*.css'
|
||||||
|
|
@ -2,9 +2,12 @@ import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './styles/global.css'
|
import './styles/global.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import { AuthGate } from './components/AuthGate.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<AuthGate>
|
||||||
|
{(user, onLogout) => <App user={user} onLogout={onLogout} />}
|
||||||
|
</AuthGate>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
120
src/styles/auth.css
Normal file
120
src/styles/auth.css
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
.auth-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 40px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-subtitle {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tabActive {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-input {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--bg-hover);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-btn {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: none;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error {
|
||||||
|
color: var(--error);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-passkey {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-hint {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 8px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"types": ["vite/client"],
|
"types": [],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,6 @@
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json" },
|
{ "path": "./tsconfig.app.json" },
|
||||||
{ "path": "./tsconfig.node.json" }
|
{ "path": "./tsconfig.server.json" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,18 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo",
|
||||||
"target": "ES2023",
|
"target": "ES2023",
|
||||||
"lib": ["ES2023"],
|
"lib": ["ES2023"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"types": ["node"],
|
"types": ["bun-types"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
|
|
@ -22,5 +20,5 @@
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["server.ts", "server"]
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react'
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
})
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue