diff --git a/.gitignore b/.gitignore index 2e262fe..ffa2c02 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ devenv.local.yaml # pre-commit .pre-commit-config.yaml + +# Database +*.db diff --git a/CLAUDE.md b/CLAUDE.md index d4a1e25..b34ec9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,29 +4,40 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 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 -- `bun run dev` — start dev server -- `bun run build` — typecheck (`tsc -b`) then build for production +- `bun run dev` — start dev server with HMR (`bun --hot server.ts`) +- `bun run build` — typecheck (`tsc -b`) - `bun run lint` — ESLint -- `bun run preview` — serve production build locally ## 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`: - **LessonMode** — structured lessons with progressive key introduction; lessons unlock sequentially (90%+ accuracy required) - **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 -**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`. -**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). **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). diff --git a/bun.lock b/bun.lock index bedca40..d5c3e41 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,8 @@ "": { "name": "temp-scaffold", "dependencies": { + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.0", "react": "^19.2.4", "react-dom": "^19.2.4", "recharts": "^3.8.0", @@ -15,6 +17,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "bun-types": "^1.3.11", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "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=="], + "@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/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=="], + "@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=="], "@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=="], "@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=="], + "@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/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=="], + "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=="], "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=="], + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "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=="], + "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-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=="], + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], + "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], "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=="], + "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=="], "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=="], + "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/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], diff --git a/package.json b/package.json index d36d0f6..1be49e3 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,14 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite --port 5174 --host", - "build": "tsc -b && vite build", + "dev": "bun --hot server.ts", + "build": "tsc -b", "lint": "eslint .", - "preview": "vite preview" + "typecheck": "tsc -b" }, "dependencies": { + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.0", "react": "^19.2.4", "react-dom": "^19.2.4", "recharts": "^3.8.0" @@ -20,6 +22,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "bun-types": "^1.3.11", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..f578eef --- /dev/null +++ b/server.ts @@ -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}`) diff --git a/server/auth.ts b/server/auth.ts new file mode 100644 index 0000000..1bacfd6 --- /dev/null +++ b/server/auth.ts @@ -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() + +// 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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), + }, + }) +} diff --git a/server/db.ts b/server/db.ts new file mode 100644 index 0000000..524501c --- /dev/null +++ b/server/db.ts @@ -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 { + 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}` +} diff --git a/src/App.tsx b/src/App.tsx index 27eed07..e3921f8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,7 +9,12 @@ import type { SessionResult } from './types' 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('lessons') const { progress, completeLesson, addSession, updateHighScore } = useStats() @@ -58,7 +63,7 @@ export default function App() { }, [updateHighScore, addSession]) return ( - + {tab === 'lessons' && ( )} diff --git a/src/components/AuthGate.tsx b/src/components/AuthGate.tsx new file mode 100644 index 0000000..b8fde95 --- /dev/null +++ b/src/components/AuthGate.tsx @@ -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(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 ( +
+
+
⌨️
+
Loading...
+
+
+ ) + } + + if (user) { + return <>{children(user, handleLogout)} + } + + return ( +
+
+
⌨️
+
Leo's Typing Tutor
+
Sign in with a passkey to track your progress
+ +
+ + +
+ + {tab === 'register' ? ( +
+ setUsername(e.target.value)} + maxLength={32} + autoFocus + required + /> + +
+ You'll be asked to create a passkey using your device's fingerprint, face, or PIN. +
+
+ ) : ( +
+ +
+ Your device will show your saved passkeys. Pick yours to sign in. +
+
+ )} + + {error &&
{error}
} +
+
+ ) +} diff --git a/src/components/GameMode.tsx b/src/components/GameMode.tsx index dd973f8..53cfc1f 100644 --- a/src/components/GameMode.tsx +++ b/src/components/GameMode.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react' -import styles from '../styles/game.module.css' -import selectorStyles from '../styles/missile.module.css' +import '../styles/game.css' +import '../styles/missile.css' import { playKeyClick, playWordComplete, playCombo, playMiss, playGameOver } from '../sounds' import { MissileGame } from './MissileGame' @@ -22,7 +22,7 @@ type FallingWord = { x: number y: number speed: number - matched: number // how many chars matched + matched: number } type Explosion = { @@ -100,7 +100,7 @@ function FallingWordsGame({ highScore, onGameOver }: Props) { const word: FallingWord = { id: nextIdRef.current++, text, - x: Math.random() * 70 + 5, // 5%-75% from left + x: Math.random() * 70 + 5, y: -5, speed: 0.2 + difficulty * 0.05, matched: 0, @@ -124,7 +124,6 @@ function FallingWordsGame({ highScore, onGameOver }: Props) { containerRef.current?.focus() } - // Game loop useEffect(() => { if (!started || gameOver) return @@ -132,17 +131,15 @@ function FallingWordsGame({ highScore, onGameOver }: Props) { const tick = (now: number) => { if (gameOverRef.current) return - const dt = (now - lastTime) / 16.67 // normalize to ~60fps + const dt = (now - lastTime) / 16.67 lastTime = now - // Spawn const spawnInterval = Math.max(2200 - difficultyRef.current * 80, 900) if (now - lastSpawnRef.current > spawnInterval) { spawnWord() lastSpawnRef.current = now } - // Move words setWords(prev => { const next: FallingWord[] = [] let lostLife = false @@ -172,7 +169,6 @@ function FallingWordsGame({ highScore, onGameOver }: Props) { return next }) - // Increase difficulty difficultyRef.current = 1 + score / 100 frameRef.current = requestAnimationFrame(tick) @@ -182,7 +178,6 @@ function FallingWordsGame({ highScore, onGameOver }: Props) { return () => cancelAnimationFrame(frameRef.current) }, [started, gameOver, spawnWord, score]) - // Fire onGameOver useEffect(() => { if (gameOver && started) { playGameOver() @@ -224,7 +219,6 @@ function FallingWordsGame({ highScore, onGameOver }: Props) { const newInput = inputRef.current + e.key - // Do matching + removal in one atomic setWords call setWords(prev => { const completed = prev.find(w => w.text === newInput) if (completed) { @@ -233,7 +227,6 @@ function FallingWordsGame({ highScore, onGameOver }: Props) { updateInput('') playWordComplete() - // Spawn explosion at word position const now = Date.now() setExplosions(ex => [...ex, { id: now, @@ -242,11 +235,8 @@ function FallingWordsGame({ highScore, onGameOver }: Props) { y: completed.y, points, }]) - // Spawn particles setParticles(p => [...p, ...spawnParticles(completed.x, completed.y, 12)]) - // Screen flash setFlash(now) - // Combo tracking setCombo(c => { const next = c + 1 if (next >= 3) { @@ -258,7 +248,6 @@ function FallingWordsGame({ highScore, onGameOver }: Props) { clearTimeout(comboTimerRef.current) comboTimerRef.current = setTimeout(() => setCombo(0), 2000) - // Auto-clean effects after animation setTimeout(() => { setExplosions(ex => ex.filter(e => e.id !== now)) }, 500) @@ -268,7 +257,6 @@ function FallingWordsGame({ highScore, onGameOver }: Props) { return prev.filter(w => w.id !== completed.id) } - // No completion — update partial matches updateInput(newInput) return prev.map(w => ({ ...w, @@ -291,20 +279,20 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
setFocused(true)} onBlur={() => setFocused(false)} > -
-
- Score - {score} +
+
+ Score + {score}
-
- Lives - +
+ Lives + {'❤️'.repeat(Math.max(0, lives))}{'🖤'.repeat(Math.max(0, 5 - lives))}
@@ -313,33 +301,31 @@ function FallingWordsGame({ highScore, onGameOver }: Props) { {words.map(w => (
0 ? styles.wordMatched : ''}`} + className={`word ${w.matched > 0 ? 'wordMatched' : ''}`} style={{ left: `${w.x}%`, top: `${w.y}%` }} > {w.text.split('').map((ch, i) => ( - + {ch} ))}
))} - {/* Exploding words */} {explosions.map(ex => (
{ex.text}
))} - {/* Particles */} {particles.map(p => (
))} - {/* Score popups */} {explosions.map(ex => (
+{ex.points}
))} - {/* Screen flash */} - {flash > 0 &&
} + {flash > 0 &&
} - {/* Combo text */} {showCombo > 0 && combo >= 3 && ( -
+
{combo}x COMBO!
)} -
+
{started && !gameOver && ( -
+
{input || type a word...}
)} {!started && ( -
-
Falling Words
-
+
+
Falling Words
+
Type the words before they fall!
- @@ -395,13 +378,13 @@ function FallingWordsGame({ highScore, onGameOver }: Props) { )} {gameOver && ( -
-
Game Over!
-
Score: {score}
+
+
Game Over!
+
Score: {score}
{score > highScore && score > 0 && ( -
New High Score!
+
New High Score!
)} -
+
+ ))} + +
+ {username} - ))} - +
+
{children}
diff --git a/src/components/LessonSelect.tsx b/src/components/LessonSelect.tsx index 2754c13..22e685e 100644 --- a/src/components/LessonSelect.tsx +++ b/src/components/LessonSelect.tsx @@ -1,6 +1,6 @@ import { LESSONS } from '../data/lessons' import type { UserProgress } from '../types' -import styles from '../styles/typing.module.css' +import '../styles/typing.css' type Props = { progress: UserProgress @@ -16,13 +16,13 @@ export function LessonSelect({ progress, onSelect }: Props) { return (
-
-

Lessons

-

+

+

Lessons

+

Master each lesson to unlock the next ({progress.completedLessons.length}/{LESSONS.length} completed)

-
+
{LESSONS.map(lesson => { const unlocked = isUnlocked(lesson.id) const completed = progress.completedLessons.includes(lesson.id) @@ -30,17 +30,17 @@ export function LessonSelect({ progress, onSelect }: Props) { return (
unlocked && onSelect(lesson.id)} > -
Lesson {lesson.id}
-
{lesson.name}
+
Lesson {lesson.id}
+
{lesson.name}
{lesson.newKeys.length > 0 && ( -
+
New: {lesson.newKeys.join(' ')}
)} -
+
{completed ? 'Completed' : unlocked ? 'Ready' : 'Locked'}
diff --git a/src/components/MissileGame.tsx b/src/components/MissileGame.tsx index bb742b3..c265b8e 100644 --- a/src/components/MissileGame.tsx +++ b/src/components/MissileGame.tsx @@ -1,5 +1,5 @@ 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 { LESSONS } from '../data/lessons' @@ -827,7 +827,7 @@ export function MissileGame({ highScore, onGameOver }: Props) {
setFocused(true)} @@ -835,33 +835,33 @@ export function MissileGame({ highScore, onGameOver }: Props) { > {/* HUD overlay */} {started && !gameOver && ( -
-
- Score - {hudScore} +
+
+ Score + {hudScore}
- Missiles -
+ Missiles +
{[0, 1, 2].map(i => ( - + 🚀 ))}
-
- HP -
+
+ HP +
{[0, 1, 2].map(i => ( -
+
))}
@@ -870,11 +870,11 @@ export function MissileGame({ highScore, onGameOver }: Props) { {/* Word prompt */} {started && !gameOver && hudWord && hudPhase === 'dodging' && ( -
+
{hudWord.split('').map((ch, i) => ( {ch} @@ -884,22 +884,22 @@ export function MissileGame({ highScore, onGameOver }: Props) { {/* Progress bar */} {started && !gameOver && ( -
-
+
+
)} {/* Ready screen */} {!started && ( -
-
Missile Strike
-
+
+
Missile Strike
+
Type words to dodge interceptors and strike the city!
3 missiles, 3 HP each. Type 4 words per missile to reach the target.
-
@@ -907,13 +907,13 @@ export function MissileGame({ highScore, onGameOver }: Props) { {/* Game over */} {gameOver && ( -
-
Mission Complete
-
Score: {finalScore}
+
+
Mission Complete
+
Score: {finalScore}
{finalScore > highScore && finalScore > 0 && ( -
New High Score!
+
New High Score!
)} -
diff --git a/src/components/ResultsModal.tsx b/src/components/ResultsModal.tsx index 60ff4a0..b23e9ed 100644 --- a/src/components/ResultsModal.tsx +++ b/src/components/ResultsModal.tsx @@ -1,4 +1,4 @@ -import styles from '../styles/typing.module.css' +import '../styles/typing.css' type Props = { wpm: number @@ -10,23 +10,23 @@ type Props = { export function ResultsModal({ wpm, accuracy, unlocked, onRetry, onBack }: Props) { return ( -
-
e.stopPropagation()}> -
+
+
e.stopPropagation()}> +
{accuracy >= 90 ? 'Great Job!' : 'Keep Practicing!'}
-
-
- {wpm} - WPM +
+
+ {wpm} + WPM
-
- {accuracy}% - Accuracy +
+ {accuracy}% + Accuracy
{unlocked && ( -
+
Unlocked: {unlocked}
)} @@ -35,9 +35,9 @@ export function ResultsModal({ wpm, accuracy, unlocked, onRetry, onBack }: Props Need 90% accuracy to unlock the next lesson
)} -
- - +
+ +
diff --git a/src/components/StatsView.tsx b/src/components/StatsView.tsx index 2b419cf..15db03d 100644 --- a/src/components/StatsView.tsx +++ b/src/components/StatsView.tsx @@ -2,8 +2,8 @@ import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianG import { LESSONS } from '../data/lessons' import { KEYBOARD_LAYOUT, FINGER_COLORS } from '../data/keyboard' import type { UserProgress } from '../types' -import styles from '../styles/game.module.css' -import kbStyles from '../styles/keyboard.module.css' +import '../styles/game.css' +import '../styles/keyboard.css' type Props = { progress: UserProgress @@ -12,7 +12,6 @@ type Props = { export function StatsView({ progress }: Props) { const { sessions, completedLessons, gameHighScore } = progress - // Build chart data from sessions (last 50) const recentSessions = sessions.slice(-50) const chartData = recentSessions.map((s, i) => ({ session: i + 1, @@ -20,7 +19,6 @@ export function StatsView({ progress }: Props) { accuracy: s.accuracy, })) - // Aggregate key stats across all sessions const aggKeyStats: Record = {} for (const s of sessions) { for (const [key, stat] of Object.entries(s.keyStats)) { @@ -43,10 +41,9 @@ export function StatsView({ progress }: Props) {

Stats & Progress

-
- {/* Summary */} -
-
Overview
+
+
+
Overview
{avgWpm}
@@ -67,20 +64,18 @@ export function StatsView({ progress }: Props) {
- {/* Lesson Progress */} -
-
Lesson Progress
+
+
Lesson Progress
{completedLessons.length} / {LESSONS.length}
-
-
+
+
- {/* WPM Chart */} -
-
WPM Over Time
+
+
WPM Over Time
{chartData.length > 0 ? ( @@ -100,9 +95,8 @@ export function StatsView({ progress }: Props) { )}
- {/* Accuracy Chart */} -
-
Accuracy Over Time
+
+
Accuracy Over Time
{chartData.length > 0 ? ( @@ -122,12 +116,11 @@ export function StatsView({ progress }: Props) { )}
- {/* Key Accuracy Heatmap */} -
-
Key Accuracy Heatmap
-
+
+
Key Accuracy Heatmap
+
{KEYBOARD_LAYOUT.map((row, ri) => ( -
+
{row.map(ki => { const stat = aggKeyStats[ki.key] let bg = FINGER_COLORS[ki.finger] @@ -146,7 +139,7 @@ export function StatsView({ progress }: Props) { return (
diff --git a/src/components/TypingArea.tsx b/src/components/TypingArea.tsx index 238c9bd..6680513 100644 --- a/src/components/TypingArea.tsx +++ b/src/components/TypingArea.tsx @@ -1,5 +1,5 @@ import { useRef, useState, useEffect } from 'react' -import styles from '../styles/typing.module.css' +import '../styles/typing.css' type Props = { text: string @@ -20,33 +20,33 @@ export function TypingArea({ text, currentIndex, errors, wpm, accuracy, onKeyDow return (
-
-
- {wpm} - WPM +
+
+ {wpm} + WPM
-
- {accuracy}% - Accuracy +
+ {accuracy}% + Accuracy
setFocused(true)} onBlur={() => setFocused(false)} > {text.split('').map((char, i) => { - let cls = styles.pending + let cls = 'pending' if (i < currentIndex) { - cls = errors.has(i) ? styles.incorrect : styles.correct + cls = errors.has(i) ? 'incorrect' : 'correct' } else if (i === currentIndex) { - cls = styles.current + cls = 'current' } return ( - + {char} ) diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..5894ae0 --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1 @@ +declare module '*.css' diff --git a/src/main.tsx b/src/main.tsx index b5d9579..969436d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,9 +2,12 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './styles/global.css' import App from './App.tsx' +import { AuthGate } from './components/AuthGate.tsx' createRoot(document.getElementById('root')!).render( - + + {(user, onLogout) => } + , ) diff --git a/src/styles/auth.css b/src/styles/auth.css new file mode 100644 index 0000000..5740911 --- /dev/null +++ b/src/styles/auth.css @@ -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; +} diff --git a/src/styles/game.module.css b/src/styles/game.css similarity index 100% rename from src/styles/game.module.css rename to src/styles/game.css diff --git a/src/styles/keyboard.module.css b/src/styles/keyboard.css similarity index 100% rename from src/styles/keyboard.module.css rename to src/styles/keyboard.css diff --git a/src/styles/missile.module.css b/src/styles/missile.css similarity index 100% rename from src/styles/missile.module.css rename to src/styles/missile.css diff --git a/src/styles/typing.module.css b/src/styles/typing.css similarity index 100% rename from src/styles/typing.module.css rename to src/styles/typing.css diff --git a/tsconfig.app.json b/tsconfig.app.json index af516fc..b3e76c4 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -5,7 +5,7 @@ "useDefineForClassFields": true, "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "ESNext", - "types": ["vite/client"], + "types": [], "skipLibCheck": true, /* Bundler mode */ diff --git a/tsconfig.json b/tsconfig.json index 1ffef60..e12bd42 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,6 @@ "files": [], "references": [ { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } + { "path": "./tsconfig.server.json" } ] } diff --git a/tsconfig.node.json b/tsconfig.server.json similarity index 74% rename from tsconfig.node.json rename to tsconfig.server.json index 8a67f62..614bf2d 100644 --- a/tsconfig.node.json +++ b/tsconfig.server.json @@ -1,20 +1,18 @@ { "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo", "target": "ES2023", "lib": ["ES2023"], "module": "ESNext", - "types": ["node"], + "types": ["bun-types"], "skipLibCheck": true, - /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "moduleDetection": "force", "noEmit": true, - /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, @@ -22,5 +20,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["vite.config.ts"] + "include": ["server.ts", "server"] } diff --git a/vite.config.ts b/vite.config.ts deleted file mode 100644 index 8b0f57b..0000000 --- a/vite.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' - -// https://vite.dev/config/ -export default defineConfig({ - plugins: [react()], -})