added server, db, auth system

This commit is contained in:
polwex 2026-04-06 22:11:34 +09:00
parent 4d3395fa1c
commit 199dab69f9
28 changed files with 989 additions and 192 deletions

3
.gitignore vendored
View file

@ -33,3 +33,6 @@ devenv.local.yaml
# pre-commit
.pre-commit-config.yaml
# Database
*.db

View file

@ -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).

View file

@ -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=="],

View file

@ -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",

34
server.ts Normal file
View 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
View 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
View 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}`
}

View file

@ -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<Tab>('lessons')
const { progress, completeLesson, addSession, updateHighScore } = useStats()
@ -58,7 +63,7 @@ export default function App() {
}, [updateHighScore, addSession])
return (
<Layout activeTab={tab} onTabChange={setTab}>
<Layout activeTab={tab} onTabChange={setTab} username={user.username} onLogout={onLogout}>
{tab === 'lessons' && (
<LessonMode progress={progress} onComplete={handleLessonComplete} />
)}

180
src/components/AuthGate.tsx Normal file
View 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>
)
}

View file

@ -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) {
<div
ref={containerRef}
className={`${styles.gameContainer} ${!focused ? styles.blurred : ''}`}
className={`gameContainer ${!focused ? 'blurred' : ''}`}
tabIndex={0}
onKeyDown={handleKeyDown}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
>
<div className={styles.gameHud}>
<div className={styles.hudItem}>
<span className={styles.hudLabel}>Score</span>
<span className={styles.hudValue}>{score}</span>
<div className="gameHud">
<div className="hudItem">
<span className="hudLabel">Score</span>
<span className="hudValue">{score}</span>
</div>
<div className={styles.hudItem}>
<span className={styles.hudLabel}>Lives</span>
<span className={`${styles.hudValue} ${styles.lives}`}>
<div className="hudItem">
<span className="hudLabel">Lives</span>
<span className="hudValue lives">
{'❤️'.repeat(Math.max(0, lives))}{'🖤'.repeat(Math.max(0, 5 - lives))}
</span>
</div>
@ -313,33 +301,31 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
{words.map(w => (
<div
key={w.id}
className={`${styles.word} ${w.matched > 0 ? styles.wordMatched : ''}`}
className={`word ${w.matched > 0 ? 'wordMatched' : ''}`}
style={{ left: `${w.x}%`, top: `${w.y}%` }}
>
{w.text.split('').map((ch, i) => (
<span key={i} className={i < w.matched ? styles.wordMatchedChar : ''}>
<span key={i} className={i < w.matched ? 'wordMatchedChar' : ''}>
{ch}
</span>
))}
</div>
))}
{/* Exploding words */}
{explosions.map(ex => (
<div
key={ex.id}
className={`${styles.word} ${styles.wordExploding}`}
className="word wordExploding"
style={{ left: `${ex.x}%`, top: `${ex.y}%` }}
>
{ex.text}
</div>
))}
{/* Particles */}
{particles.map(p => (
<div
key={p.id}
className={styles.particle}
className="particle"
style={{
left: `${p.x}%`,
top: `${p.y}%`,
@ -352,42 +338,39 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
/>
))}
{/* Score popups */}
{explosions.map(ex => (
<div
key={`score-${ex.id}`}
className={styles.scorePopup}
className="scorePopup"
style={{ left: `${ex.x + 3}%`, top: `${ex.y - 2}%` }}
>
+{ex.points}
</div>
))}
{/* Screen flash */}
{flash > 0 && <div key={flash} className={styles.screenFlash} />}
{flash > 0 && <div key={flash} className="screenFlash" />}
{/* Combo text */}
{showCombo > 0 && combo >= 3 && (
<div key={showCombo} className={styles.comboText}>
<div key={showCombo} className="comboText">
{combo}x COMBO!
</div>
)}
<div className={styles.dangerZone} />
<div className="dangerZone" />
{started && !gameOver && (
<div className={styles.inputDisplay}>
<div className="inputDisplay">
{input || <span style={{ opacity: 0.3 }}>type a word...</span>}
</div>
)}
{!started && (
<div className={styles.gameOver}>
<div className={styles.gameOverTitle}>Falling Words</div>
<div className={styles.gameOverScore}>
<div className="gameOver">
<div className="gameOverTitle">Falling Words</div>
<div className="gameOverScore">
Type the words before they fall!
</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' }}>
Start Game
</button>
@ -395,13 +378,13 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
)}
{gameOver && (
<div className={styles.gameOver}>
<div className={styles.gameOverTitle}>Game Over!</div>
<div className={styles.gameOverScore}>Score: {score}</div>
<div className="gameOver">
<div className="gameOverTitle">Game Over!</div>
<div className="gameOverScore">Score: {score}</div>
{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
onClick={startGame}
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 }}>
High Score: {highScore}
</p>
<div className={selectorStyles.selector}>
<div className={selectorStyles.selectorCard} onClick={() => setChoice('falling')}>
<div className={selectorStyles.selectorIcon}>🌧</div>
<div className={selectorStyles.selectorTitle}>Falling Words</div>
<div className={selectorStyles.selectorDesc}>
<div className="selector">
<div className="selectorCard" onClick={() => setChoice('falling')}>
<div className="selectorIcon">🌧</div>
<div className="selectorTitle">Falling Words</div>
<div className="selectorDesc">
Type falling words before they hit the ground. Build combos for bonus points!
</div>
</div>
<div className={selectorStyles.selectorCard} onClick={() => setChoice('missile')}>
<div className={selectorStyles.selectorIcon}>🚀</div>
<div className={selectorStyles.selectorTitle}>Missile Strike</div>
<div className={selectorStyles.selectorDesc}>
<div className="selectorCard" onClick={() => setChoice('missile')}>
<div className="selectorIcon">🚀</div>
<div className="selectorTitle">Missile Strike</div>
<div className="selectorDesc">
Launch missiles at the enemy city. Type words to dodge interceptors!
</div>
</div>

View file

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { KEYBOARD_LAYOUT, FINGER_COLORS, FINGER_LABELS } from '../data/keyboard'
import type { KeyInfo } from '../types'
import styles from '../styles/keyboard.module.css'
import '../styles/keyboard.css'
type Props = {
activeKey?: string
@ -39,9 +39,9 @@ export function Keyboard({ activeKey, keyStats }: Props) {
}
return (
<div className={styles.keyboard}>
<div className="keyboard">
{KEYBOARD_LAYOUT.map((row, ri) => (
<div className={styles.row} key={ri}>
<div className="row" key={ri}>
{row.map(ki => {
const isActive = activeKey !== undefined &&
ki.key === activeKey.toLowerCase()
@ -51,14 +51,14 @@ export function Keyboard({ activeKey, keyStats }: Props) {
return (
<div
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={{
background: getKeyBg(ki),
color: '#fff',
}}
>
{label}
<span className={styles.fingerLabel}>{FINGER_LABELS[ki.finger]}</span>
<span className="fingerLabel">{FINGER_LABELS[ki.finger]}</span>
</div>
)
})}

View file

@ -5,6 +5,8 @@ type Tab = 'lessons' | 'free' | 'game' | 'stats'
type Props = {
activeTab: Tab
onTabChange: (tab: Tab) => void
username: string
onLogout: () => void
children: ReactNode
}
@ -15,7 +17,7 @@ const TABS: { id: Tab; label: string; icon: string }[] = [
{ id: 'stats', label: 'Stats', icon: '📊' },
]
export function Layout({ activeTab, onTabChange, children }: Props) {
export function Layout({ activeTab, onTabChange, username, onLogout, children }: Props) {
return (
<div style={{ maxWidth: 960, margin: '0 auto', padding: '20px 24px' }}>
<header style={{
@ -33,24 +35,41 @@ export function Layout({ activeTab, onTabChange, children }: Props) {
}}>
Leo's Typing Tutor
</h1>
<nav style={{ display: 'flex', gap: 4 }}>
{TABS.map(tab => (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<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
key={tab.id}
onClick={() => onTabChange(tab.id)}
onClick={onLogout}
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',
background: 'var(--bg-card)',
color: 'var(--text-dim)',
fontSize: 12,
padding: '6px 12px',
borderRadius: 'var(--radius)',
fontSize: 14,
}}
>
{tab.icon} {tab.label}
Logout
</button>
))}
</nav>
</div>
</div>
</header>
<main>{children}</main>
</div>

View file

@ -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 (
<div>
<div className={styles.modeHeader}>
<h2 className={styles.modeTitle}>Lessons</h2>
<p className={styles.modeSubtitle}>
<div className="modeHeader">
<h2 className="modeTitle">Lessons</h2>
<p className="modeSubtitle">
Master each lesson to unlock the next ({progress.completedLessons.length}/{LESSONS.length} completed)
</p>
</div>
<div className={styles.lessonSelect}>
<div className="lessonSelect">
{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 (
<div
key={lesson.id}
className={`${styles.lessonCard} ${!unlocked ? styles.locked : ''} ${completed ? styles.completed : ''}`}
className={`lessonCard ${!unlocked ? 'locked' : ''} ${completed ? 'completed' : ''}`}
onClick={() => unlocked && onSelect(lesson.id)}
>
<div className={styles.lessonId}>Lesson {lesson.id}</div>
<div className={styles.lessonName}>{lesson.name}</div>
<div className="lessonId">Lesson {lesson.id}</div>
<div className="lessonName">{lesson.name}</div>
{lesson.newKeys.length > 0 && (
<div className={styles.lessonKeys}>
<div className="lessonKeys">
New: {lesson.newKeys.join(' ')}
</div>
)}
<div className={styles.lessonStatus}>
<div className="lessonStatus">
{completed ? 'Completed' : unlocked ? 'Ready' : 'Locked'}
</div>
</div>

View file

@ -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) {
<div
ref={wrapperRef}
className={`${styles.wrapper} ${!focused ? styles.blurred : ''} ${shaking ? styles.shake : ''}`}
className={`wrapper ${!focused ? 'blurred' : ''} ${shaking ? 'shake' : ''}`}
tabIndex={0}
onKeyDown={handleKeyDown}
onFocus={() => setFocused(true)}
@ -835,33 +835,33 @@ export function MissileGame({ highScore, onGameOver }: Props) {
>
<canvas
ref={canvasRef}
className={styles.canvas}
className="canvas"
width={CANVAS_W}
height={CANVAS_H}
/>
{/* HUD overlay */}
{started && !gameOver && (
<div className={styles.hud}>
<div className={styles.hudLeft}>
<span className={styles.hudLabel}>Score</span>
<span className={styles.hudValue}>{hudScore}</span>
<div className="hud">
<div className="hudLeft">
<span className="hudLabel">Score</span>
<span className="hudValue">{hudScore}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
<span className={styles.hudLabel}>Missiles</span>
<div className={styles.missiles}>
<span className="hudLabel">Missiles</span>
<div className="missiles">
{[0, 1, 2].map(i => (
<span key={i} className={`${styles.missilePip} ${i < hudMissilesLeft ? styles.active : ''}`}>
<span key={i} className={`missilePip ${i < hudMissilesLeft ? 'active' : ''}`}>
🚀
</span>
))}
</div>
</div>
<div className={styles.hudRight}>
<span className={styles.hudLabel}>HP</span>
<div className={styles.hpPips}>
<div className="hudRight">
<span className="hudLabel">HP</span>
<div className="hpPips">
{[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>
@ -870,11 +870,11 @@ export function MissileGame({ highScore, onGameOver }: Props) {
{/* Word prompt */}
{started && !gameOver && hudWord && hudPhase === 'dodging' && (
<div className={styles.wordPrompt}>
<div className="wordPrompt">
{hudWord.split('').map((ch, i) => (
<span
key={i}
className={i < hudTypedIndex ? styles.typedChar : i === hudTypedIndex ? styles.cursor : ''}
className={i < hudTypedIndex ? 'typedChar' : i === hudTypedIndex ? 'cursor' : ''}
>
{ch}
</span>
@ -884,22 +884,22 @@ export function MissileGame({ highScore, onGameOver }: Props) {
{/* Progress bar */}
{started && !gameOver && (
<div className={styles.progressWrap}>
<div className={styles.progressFill} style={{ width: `${hudProgress * 100}%` }} />
<div className="progressWrap">
<div className="progressFill" style={{ width: `${hudProgress * 100}%` }} />
</div>
)}
{/* Ready screen */}
{!started && (
<div className={styles.overlay}>
<div className={styles.overlayTitle}>Missile Strike</div>
<div className={styles.overlaySubtitle}>
<div className="overlay">
<div className="overlayTitle">Missile Strike</div>
<div className="overlaySubtitle">
Type words to dodge interceptors and strike the city!
</div>
<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.
</div>
<button className={styles.overlayBtn} onClick={startGame}>
<button className="overlayBtn" onClick={startGame}>
Launch!
</button>
</div>
@ -907,13 +907,13 @@ export function MissileGame({ highScore, onGameOver }: Props) {
{/* Game over */}
{gameOver && (
<div className={styles.overlay}>
<div className={styles.overlayTitle}>Mission Complete</div>
<div className={styles.overlaySubtitle}>Score: {finalScore}</div>
<div className="overlay">
<div className="overlayTitle">Mission Complete</div>
<div className="overlaySubtitle">Score: {finalScore}</div>
{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
</button>
</div>

View file

@ -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 (
<div className={styles.modal} onClick={onBack}>
<div className={styles.modalContent} onClick={e => e.stopPropagation()}>
<div className={styles.modalTitle}>
<div className="modal" onClick={onBack}>
<div className="modalContent" onClick={e => e.stopPropagation()}>
<div className="modalTitle">
{accuracy >= 90 ? 'Great Job!' : 'Keep Practicing!'}
</div>
<div className={styles.modalStats}>
<div className={styles.stat}>
<span className={styles.statValue}>{wpm}</span>
<span className={styles.statLabel}>WPM</span>
<div className="modalStats">
<div className="stat">
<span className="statValue">{wpm}</span>
<span className="statLabel">WPM</span>
</div>
<div className={styles.stat}>
<span className={styles.statValue}>{accuracy}%</span>
<span className={styles.statLabel}>Accuracy</span>
<div className="stat">
<span className="statValue">{accuracy}%</span>
<span className="statLabel">Accuracy</span>
</div>
</div>
{unlocked && (
<div className={styles.unlockText}>
<div className="unlockText">
Unlocked: {unlocked}
</div>
)}
@ -35,9 +35,9 @@ export function ResultsModal({ wpm, accuracy, unlocked, onRetry, onBack }: Props
Need 90% accuracy to unlock the next lesson
</div>
)}
<div className={styles.modalButtons}>
<button className={styles.secondaryBtn} onClick={onBack}>Back</button>
<button className={styles.primaryBtn} onClick={onRetry}>Try Again</button>
<div className="modalButtons">
<button className="secondaryBtn" onClick={onBack}>Back</button>
<button className="primaryBtn" onClick={onRetry}>Try Again</button>
</div>
</div>
</div>

View file

@ -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<string, { hits: number; misses: number }> = {}
for (const s of sessions) {
for (const [key, stat] of Object.entries(s.keyStats)) {
@ -43,10 +41,9 @@ export function StatsView({ progress }: Props) {
<div className="fade-in">
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 20 }}>Stats & Progress</h2>
<div className={styles.statsGrid}>
{/* Summary */}
<div className={styles.statsCard}>
<div className={styles.statsCardTitle}>Overview</div>
<div className="statsGrid">
<div className="statsCard">
<div className="statsCardTitle">Overview</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<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>
{/* Lesson Progress */}
<div className={styles.statsCard}>
<div className={styles.statsCardTitle}>Lesson Progress</div>
<div className="statsCard">
<div className="statsCardTitle">Lesson Progress</div>
<div style={{ fontSize: 28, fontWeight: 700, color: 'var(--accent)', marginBottom: 12, fontFamily: 'var(--font-mono)' }}>
{completedLessons.length} / {LESSONS.length}
</div>
<div className={styles.progressBar}>
<div className={styles.progressFill} style={{ width: `${lessonProgress}%` }} />
<div className="progressBar">
<div className="progressFill" style={{ width: `${lessonProgress}%` }} />
</div>
</div>
{/* WPM Chart */}
<div className={styles.statsCard}>
<div className={styles.statsCardTitle}>WPM Over Time</div>
<div className="statsCard">
<div className="statsCardTitle">WPM Over Time</div>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={chartData}>
@ -100,9 +95,8 @@ export function StatsView({ progress }: Props) {
)}
</div>
{/* Accuracy Chart */}
<div className={styles.statsCard}>
<div className={styles.statsCardTitle}>Accuracy Over Time</div>
<div className="statsCard">
<div className="statsCardTitle">Accuracy Over Time</div>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={chartData}>
@ -122,12 +116,11 @@ export function StatsView({ progress }: Props) {
)}
</div>
{/* Key Accuracy Heatmap */}
<div className={styles.statsCard} style={{ gridColumn: '1 / -1' }}>
<div className={styles.statsCardTitle}>Key Accuracy Heatmap</div>
<div className={kbStyles.keyboard}>
<div className="statsCard" style={{ gridColumn: '1 / -1' }}>
<div className="statsCardTitle">Key Accuracy Heatmap</div>
<div className="keyboard">
{KEYBOARD_LAYOUT.map((row, ri) => (
<div className={kbStyles.row} key={ri}>
<div className="row" key={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 (
<div
key={ki.key}
className={`${kbStyles.key} ${ki.key === ' ' ? kbStyles.space : ''}`}
className={`key ${ki.key === ' ' ? 'space' : ''}`}
style={{ background: bg, opacity, color: '#fff' }}
title={stat ? `${ki.key}: ${Math.round((stat.hits / (stat.hits + stat.misses)) * 100)}% accuracy` : `${ki.key}: no data`}
>

View file

@ -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 (
<div>
<div className={styles.stats}>
<div className={styles.stat}>
<span className={styles.statValue}>{wpm}</span>
<span className={styles.statLabel}>WPM</span>
<div className="stats">
<div className="stat">
<span className="statValue">{wpm}</span>
<span className="statLabel">WPM</span>
</div>
<div className={styles.stat}>
<span className={styles.statValue}>{accuracy}%</span>
<span className={styles.statLabel}>Accuracy</span>
<div className="stat">
<span className="statValue">{accuracy}%</span>
<span className="statLabel">Accuracy</span>
</div>
</div>
<div
ref={ref}
className={`${styles.typingArea} ${!focused ? styles.blurred : ''}`}
className={`typingArea ${!focused ? 'blurred' : ''}`}
tabIndex={0}
onKeyDown={onKeyDown}
onFocus={() => 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 (
<span key={i} className={`${styles.char} ${cls}`}>
<span key={i} className={`char ${cls}`}>
{char}
</span>
)

1
src/env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
declare module '*.css'

View file

@ -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(
<StrictMode>
<App />
<AuthGate>
{(user, onLogout) => <App user={user} onLogout={onLogout} />}
</AuthGate>
</StrictMode>,
)

120
src/styles/auth.css Normal file
View 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;
}

View file

@ -5,7 +5,7 @@
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"types": [],
"skipLibCheck": true,
/* Bundler mode */

View file

@ -2,6 +2,6 @@
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
{ "path": "./tsconfig.server.json" }
]
}

View file

@ -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"]
}

View file

@ -1,7 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})