From 46fb7dbdd286e9d215af9db7ae8570ef90782768 Mon Sep 17 00:00:00 2001 From: lila Date: Tue, 14 Apr 2026 18:33:30 +0200 Subject: [PATCH 01/13] adding docker and openssh client installation --- .forgejo/workflows/deploy.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 306cc78..a48cee1 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -8,6 +8,9 @@ jobs: build-and-deploy: runs-on: docker steps: + - name: Install tools + run: apt-get update && apt-get install -y docker.io openssh-client + - name: Checkout code uses: https://data.forgejo.org/actions/checkout@v4 From 3b2ecf6ee36289dbea3f2ac7f21cc68519cbaafa Mon Sep 17 00:00:00 2001 From: lila Date: Tue, 14 Apr 2026 18:56:59 +0200 Subject: [PATCH 02/13] adding debugging step --- .forgejo/workflows/deploy.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index a48cee1..062801c 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -11,6 +11,13 @@ jobs: - name: Install tools run: apt-get update && apt-get install -y docker.io openssh-client + - name: Debug Docker + run: | + ls -la /var/run/docker.sock || echo "Socket not found" + whoami + id + docker version || echo "Docker command failed" + - name: Checkout code uses: https://data.forgejo.org/actions/checkout@v4 From 201f462447beb13f786b4b259adb6b07eef68ce1 Mon Sep 17 00:00:00 2001 From: lila Date: Tue, 14 Apr 2026 19:19:07 +0200 Subject: [PATCH 03/13] cleaning up --- .forgejo/workflows/deploy.yml | 7 ------- documentation/notes.md | 4 ++++ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 062801c..a48cee1 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -11,13 +11,6 @@ jobs: - name: Install tools run: apt-get update && apt-get install -y docker.io openssh-client - - name: Debug Docker - run: | - ls -la /var/run/docker.sock || echo "Socket not found" - whoami - id - docker version || echo "Docker command failed" - - name: Checkout code uses: https://data.forgejo.org/actions/checkout@v4 diff --git a/documentation/notes.md b/documentation/notes.md index 7d1b801..b0b276e 100644 --- a/documentation/notes.md +++ b/documentation/notes.md @@ -11,6 +11,10 @@ verify if hetzner domain needs to be pushed, theres a change on hetzner and some domains need to be migrated +### redirect or page not found + +subdomains or pages that dont exist should have page not found or should redirect + ### docker credential helper WARNING! Your credentials are stored unencrypted in '/home/languagedev/.docker/config.json'. From e5595b5039d2f5ed4d207ae8393671052e26cfe1 Mon Sep 17 00:00:00 2001 From: lila Date: Tue, 14 Apr 2026 19:35:49 +0200 Subject: [PATCH 04/13] updating documentation --- documentation/decisions.md | 68 +++++++++++++++++++++++ documentation/deployment.md | 52 +++++++++++++++++- documentation/notes.md | 10 ++-- documentation/roadmap.md | 95 ++++++++++++++++++++++++-------- documentation/spec.md | 104 ++++++++++++++++++++++++------------ 5 files changed, 268 insertions(+), 61 deletions(-) diff --git a/documentation/decisions.md b/documentation/decisions.md index 4b705c0..cdf41b3 100644 --- a/documentation/decisions.md +++ b/documentation/decisions.md @@ -359,3 +359,71 @@ All deferred post-MVP, purely additive (new tables referencing existing `terms`) - `noun_forms` — gender, singular, plural, articles per language (source: Wiktionary) - `verb_forms` — conjugation tables per language (source: Wiktionary) - `term_pronunciations` — IPA and audio URLs per language (source: Wiktionary / Forvo) + +--- + +## Deployment + +### Reverse proxy: Caddy (not Nginx, not Traefik) + +Caddy provides automatic HTTPS via Let's Encrypt with zero configuration beyond specifying domain names. The entire Caddyfile is ~10 lines. Nginx would require manual certbot setup and more verbose config. Traefik's auto-discovery of Docker containers (via labels) is powerful but overkill for a stable three-service stack where routing rules never change. Caddy runs as a Docker container alongside the app — no native install. + +### Subdomain routing (not path-based) + +`lilastudy.com` serves the frontend, `api.lilastudy.com` serves the API, `git.lilastudy.com` serves Forgejo. Cleaner separation than path-based routing — any service can be moved to a different server just by changing DNS. Requires CORS configuration since the browser sees different origins, and cross-subdomain cookies via `COOKIE_DOMAIN=.lilastudy.com`. Wildcard DNS (`*.lilastudy.com`) means new subdomains require no DNS changes. + +### Frontend served by nginx:alpine (not Node, not Caddy) + +Vite builds to static files. Serving them with nginx inside the container is lighter than running a Node process and keeps the container at ~7MB. Caddy could serve them directly, but using a separate container maintains the one-service-per-container principle and keeps Caddy's config purely about routing. + +### SPA fallback via nginx `try_files` + +Without `try_files $uri $uri/ /index.html`, refreshing on `/play` returns 404 because there's no actual `play` file. Nginx serves `index.html` for all routes and lets TanStack Router handle client-side routing. + +### Forgejo as git server + container registry (not GitHub, not Docker Hub) + +Keeps everything self-hosted on one VPS. Forgejo's built-in package registry doubles as a container registry, eliminating a separate service. Git push and image push go to the same server. + +### Forgejo SSH on port 2222 (not 22) + +Port 22 is the VPS's own SSH. Mapping Forgejo's SSH to 2222 avoids conflicts. Dev laptop `~/.ssh/config` maps `git.lilastudy.com` to port 2222 so git commands work without specifying the port every time. + +### `packages/db` and `packages/shared` exports: compiled JS paths + +Exports in both package.json files point to `./dist/src/index.js`, not TypeScript source. In dev, `tsx` can run TypeScript, but in production Node cannot. This means packages must be built before the API starts in dev — acceptable since these packages change infrequently. Alternative approaches (conditional exports, tsconfig paths) were considered but added complexity for no practical benefit. + +### Environment-driven config for production vs dev + +CORS origin, Better Auth base URL, cookie domain, API URL, and OAuth credentials are all read from environment variables with localhost fallbacks. The same code runs in both environments without changes. `VITE_API_URL` is the exception — it's baked in at build time via Docker build arg because Vite replaces `import.meta.env` at compile time, not runtime. + +### Cross-subdomain cookies + +Better Auth's `defaultCookieAttributes` sets `domain: .lilastudy.com` in production (from env var `COOKIE_DOMAIN`). Without this, the auth cookie scoped to `api.lilastudy.com` wouldn't be sent on requests from `lilastudy.com`. The leading dot makes the cookie valid across all subdomains. + +--- + +## CI/CD + +### Forgejo Actions with SSH deploy (not webhooks, not manual) + +CI builds images natively on the ARM64 VPS (no QEMU cross-compilation). The runner uses the host's Docker socket to build. After pushing to the registry, the workflow SSHs into the VPS to pull and restart containers. Webhooks were considered but add an extra listener service to maintain and secure. Manual deploy was the initial approach but doesn't scale with frequent pushes. + +### Dedicated CI SSH key + +A separate `ci-runner` SSH key pair (not the developer's personal key) is used for CI deploys. The private key is stored in Forgejo's secrets. If compromised, only this key needs to be revoked — the developer's access is unaffected. + +### Runner config: `docker_host: "automount"` + `valid_volumes` + explicit config path + +The Forgejo runner's `automount` setting mounts the host Docker socket into job containers. `valid_volumes` must include `/var/run/docker.sock` or the mount is blocked. The runner command must explicitly reference the config file (`-c /data/config.yml`) — without this flag, config changes are silently ignored. `--group-add 989` in container options adds the host's docker group so job containers can access the socket. + +### Docker CLI installed per job (not baked into runner image) + +The job container (`node:24-bookworm`) doesn't include Docker CLI. It's installed via `apt-get install docker.io` as the first workflow step. This adds ~20 seconds per run but avoids maintaining a custom runner image. The CLI sends commands through the mounted socket to the host's Docker engine. + +--- + +## Backups + +### pg_dump cron + dev laptop sync (not WAL archiving, not managed service) + +Daily compressed SQL dumps with 7-day retention. Dev laptop auto-syncs new backups on login via rsync. Simple, portable, sufficient for current scale. WAL archiving gives point-in-time recovery but is complex to set up. Offsite storage (Hetzner Object Storage) is the planned next step — backups on the same VPS don't protect against VPS failure. diff --git a/documentation/deployment.md b/documentation/deployment.md index 5912d2f..f95fea4 100644 --- a/documentation/deployment.md +++ b/documentation/deployment.md @@ -225,9 +225,59 @@ Host git.lilastudy.com This allows standard git commands without specifying the port. +## CI/CD Pipeline + +Automated build and deploy via Forgejo Actions. On every push to `main`, the pipeline builds ARM64 images natively on the VPS, pushes them to the Forgejo registry, and restarts the app containers. + +### Components + +- **Forgejo Actions** — enabled by default, workflow files in `.forgejo/workflows/` +- **Forgejo Runner** — runs as a container (`lila-ci-runner`) on the VPS, uses the host's Docker socket to build images natively on ARM64 +- **Workflow file** — `.forgejo/workflows/deploy.yml` + +### Pipeline Steps + +1. Install Docker CLI and SSH client in the job container +2. Checkout the repository +3. Login to the Forgejo container registry +4. Build API image (target: `runner`) +5. Build Web image (target: `production`, with `VITE_API_URL` baked in) +6. Push both images to `git.lilastudy.com` +7. SSH into the VPS, pull new images, restart `api` and `web` containers, prune old images + +### Secrets (stored in Forgejo repo settings → Actions → Secrets) + +| Secret | Value | +|---|---| +| REGISTRY_USER | Forgejo username | +| REGISTRY_PASSWORD | Forgejo password | +| SSH_PRIVATE_KEY | Contents of `~/.ssh/ci-runner` on the VPS | +| SSH_HOST | VPS IP address | +| SSH_USER | `lila` | + +### Runner Configuration + +The runner config is at `/data/config.yml` inside the `lila-ci-runner` container. Key settings: + +- `docker_host: "automount"` — mounts the host Docker socket into job containers +- `valid_volumes: ["/var/run/docker.sock"]` — allows the socket mount +- `privileged: true` — required for Docker access from job containers +- `options: "--group-add 989"` — adds the host's docker group (GID 989) to job containers + +The runner command must explicitly reference the config file: + +```yaml +command: '/bin/sh -c "sleep 5; forgejo-runner -c /data/config.yml daemon"' +``` + +### Deploy Cycle + +Push to main → pipeline runs automatically (~2-5 min) → app is updated. No manual steps required. + +To manually trigger a re-run: go to the repo's Actions tab, click on the latest run, and use the re-run button. + ## Known Issues and Future Work -- **CI/CD**: Currently manual build-push-pull cycle. Plan: Forgejo Actions with a runner on the VPS building ARM images natively (eliminates QEMU cross-compilation) - **Backups**: Offsite backup storage (Hetzner Object Storage or similar) should be added - **Valkey**: Not in the production stack yet. Will be added when multiplayer requires session/room state - **Monitoring/logging**: No centralized logging or uptime monitoring configured diff --git a/documentation/notes.md b/documentation/notes.md index b0b276e..998cabf 100644 --- a/documentation/notes.md +++ b/documentation/notes.md @@ -24,15 +24,15 @@ https://docs.docker.com/go/credential-store/ ### vps setup - monitoring and logging (eg via chrootkit or rkhunter, logwatch/monit => mails daily with summary) -- keep the vps clean (e.g. old docker images/containers) +- ~~keep the vps clean (e.g. old docker images/containers)~~ ✅ CI/CD pipeline runs `docker image prune -f` after deploy -### cd/ci pipeline +### ~~cd/ci pipeline~~ ✅ RESOLVED -forgejo actions? smth else? where docker registry, also forgejo? +Forgejo Actions with runner on VPS, Forgejo built-in container registry. See `deployment.md`. -### postgres backups +### ~~postgres backups~~ ✅ RESOLVED -how? +Daily pg_dump cron job, 7-day retention, dev laptop auto-sync via rsync. See `deployment.md`. ### try now option diff --git a/documentation/roadmap.md b/documentation/roadmap.md index 825cbc9..7c4b4ed 100644 --- a/documentation/roadmap.md +++ b/documentation/roadmap.md @@ -91,7 +91,7 @@ Each phase produces a working increment. Nothing is built speculatively. --- -## Phase 3 — Auth +## Phase 3 — Auth ✅ **Goal:** Users can log in via Google or GitHub and stay logged in. **Done when:** Better Auth session is validated on protected routes; unauthenticated users are redirected to login; user row is created on first social login. @@ -109,6 +109,68 @@ Each phase produces a working increment. Nothing is built speculatively. --- +## Phase 6 — Production Deployment ✅ + +**Goal:** App is live on Hetzner, accessible via HTTPS on all subdomains. +**Done when:** `https://lilastudy.com` loads; `https://api.lilastudy.com` responds; auth flow works end-to-end; CI/CD deploys on push to main. + +_Note: Deployment was moved ahead of multiplayer — the app is useful without multiplayer but not without deployment._ + +### Infrastructure + +- [x] Hetzner VPS provisioned (Debian 13, ARM64, 4GB RAM) +- [x] SSH hardening, ufw firewall, fail2ban +- [x] Docker + Docker Compose installed +- [x] Domain DNS: A record + wildcard `*.lilastudy.com` pointing to VPS + +### Reverse proxy + +- [x] Caddy container with automatic HTTPS (Let's Encrypt) +- [x] Subdomain routing: `lilastudy.com` → web, `api.lilastudy.com` → API, `git.lilastudy.com` → Forgejo + +### Docker stack + +- [x] Production `docker-compose.yml` with all services on shared network +- [x] No ports exposed on internal services — only Caddy (80/443) and Forgejo SSH (2222) +- [x] Production Dockerfile stages for API (runner) and frontend (nginx:alpine) +- [x] Monorepo package exports fixed for production (dist/src paths) +- [x] Production `.env` with env-driven CORS, auth URLs, cookie domain + +### Git server + container registry + +- [x] Forgejo running with built-in container registry +- [x] SSH on port 2222, dev laptop `~/.ssh/config` configured +- [x] Repository created, code pushed + +### CI/CD + +- [x] Forgejo Actions enabled +- [x] Forgejo Runner container on VPS with Docker socket access +- [x] `.forgejo/workflows/deploy.yml` — build, push, deploy via SSH on push to main +- [x] Registry and SSH secrets configured in Forgejo + +### Database + +- [x] Initial seed via pg_dump from dev laptop +- [x] Seeding script is idempotent (onConflictDoNothing) for future data additions +- [x] Schema migrations via Drizzle (migrate first, deploy second) + +### OAuth + +- [x] Google and GitHub OAuth redirect URIs configured for production +- [x] Cross-subdomain cookies via COOKIE_DOMAIN=.lilastudy.com + +### Backups + +- [x] Daily cron job (3 AM) with pg_dump, 7-day retention +- [x] Dev laptop auto-syncs backups on login via rsync + +### Documentation + +- [x] `deployment.md` covering full infrastructure setup + +--- + ## Phase 4 — Multiplayer Lobby **Goal:** Players can create and join rooms; the host sees all joined players in real time. @@ -148,32 +210,21 @@ Each phase produces a working increment. Nothing is built speculatively. --- -## Phase 6 — Production Deployment - -**Goal:** App is live on Hetzner, accessible via HTTPS on all subdomains. -**Done when:** `https://app.yourdomain.com` loads; `wss://api.yourdomain.com` connects; auth flow works end-to-end. - -- [ ] `docker-compose.prod.yml`: all services + `nginx-proxy` + `acme-companion` -- [ ] Nginx config per container: `VIRTUAL_HOST` + `LETSENCRYPT_HOST` -- [ ] Production `.env` files on VPS -- [ ] Drizzle migration runs on `api` container start -- [ ] Seed production DB -- [ ] Smoke test: login → solo game → multiplayer game end-to-end - ---- - ## Phase 7 — Polish & Hardening **Goal:** Production-ready for real users. +- [x] CI/CD pipeline (Forgejo Actions → SSH deploy) +- [x] Database backups (cron → dev laptop sync) - [ ] Rate limiting on API endpoints - [ ] Graceful WS reconnect with exponential back-off - [ ] React error boundaries - [ ] `GET /users/me/stats` endpoint + profile page - [ ] Accessibility pass (keyboard nav, ARIA on quiz buttons) - [ ] Favicon, page titles, Open Graph meta -- [ ] CI/CD pipeline (GitHub Actions → SSH deploy) -- [ ] Database backups (cron → Hetzner Object Storage) +- [ ] Offsite backup storage (Hetzner Object Storage) +- [ ] Monitoring/logging (uptime, centralized logs) +- [ ] Valkey for game session store (replace in-memory) --- @@ -183,9 +234,9 @@ Each phase produces a working increment. Nothing is built speculatively. Phase 0 (Foundation) ✅ └── Phase 1 (Vocabulary Data + API) ✅ └── Phase 2 (Singleplayer UI) ✅ - └── Phase 3 (Auth) - ├── Phase 4 (Multiplayer Lobby) - │ └── Phase 5 (Multiplayer Game) - │ └── Phase 6 (Deployment) - └── Phase 7 (Hardening) + ├── Phase 3 (Auth) ✅ + │ └── Phase 6 (Deployment + CI/CD) ✅ + └── Phase 4 (Multiplayer Lobby) + └── Phase 5 (Multiplayer Game) + └── Phase 7 (Hardening) ``` diff --git a/documentation/spec.md b/documentation/spec.md index 8539dac..4bf2835 100644 --- a/documentation/spec.md +++ b/documentation/spec.md @@ -63,9 +63,9 @@ These are not deleted from the plan — they are deferred. The architecture is a ## 4. Technology Stack -The monorepo structure and tooling are already set up. This is the full stack — the MVP uses a subset of it. +The monorepo structure and tooling are already set up. This is the full stack. -| Layer | Technology | MVP? | +| Layer | Technology | Status | | ------------ | ------------------------------ | ----------- | | Monorepo | pnpm workspaces | ✅ | | Frontend | React 18, Vite, TypeScript | ✅ | @@ -77,10 +77,11 @@ The monorepo structure and tooling are already set up. This is the full stack | Database | PostgreSQL + Drizzle ORM | ✅ | | Validation | Zod (shared schemas) | ✅ | | Testing | Vitest, supertest | ✅ | -| Auth | Better Auth (Google + GitHub) | ❌ post-MVP | +| Auth | Better Auth (Google + GitHub) | ✅ | +| Deployment | Docker Compose, Caddy, Hetzner | ✅ | +| CI/CD | Forgejo Actions | ✅ | | Realtime | WebSockets (`ws` library) | ❌ post-MVP | | Cache | Valkey | ❌ post-MVP | -| Deployment | Docker Compose, Hetzner, Nginx | ❌ post-MVP | --- @@ -88,14 +89,20 @@ The monorepo structure and tooling are already set up. This is the full stack ```text vocab-trainer/ +├── .forgejo/ +│ └── workflows/ +│ └── deploy.yml — CI/CD pipeline (build, push, deploy) ├── apps/ │ ├── api/ │ │ └── src/ -│ │ ├── app.ts — createApp() factory, express.json(), error middleware +│ │ ├── app.ts — createApp() factory, CORS, auth handler, error middleware │ │ ├── server.ts — starts server on PORT │ │ ├── errors/ │ │ │ └── AppError.ts — AppError, ValidationError, NotFoundError +│ │ ├── lib/ +│ │ │ └── auth.ts — Better Auth config (Google + GitHub providers) │ │ ├── middleware/ +│ │ │ ├── authMiddleware.ts — session validation for protected routes │ │ │ └── errorHandler.ts — central error middleware │ │ ├── routes/ │ │ │ ├── apiRouter.ts — mounts /health and /game routers @@ -111,10 +118,17 @@ vocab-trainer/ │ │ ├── InMemoryGameSessionStore.ts │ │ └── index.ts │ └── web/ +│ ├── Dockerfile — multi-stage: dev + production (nginx:alpine) +│ ├── nginx.conf — SPA fallback routing │ └── src/ │ ├── routes/ │ │ ├── index.tsx — landing page -│ │ └── play.tsx — the quiz +│ │ ├── play.tsx — the quiz +│ │ ├── login.tsx — Google + GitHub login buttons +│ │ ├── about.tsx +│ │ └── __root.tsx +│ ├── lib/ +│ │ └── auth-client.ts — Better Auth React client │ ├── components/ │ │ └── game/ │ │ ├── GameSetup.tsx — settings UI @@ -131,7 +145,7 @@ vocab-trainer/ │ └── db/ │ ├── drizzle/ — migration SQL files │ └── src/ -│ ├── db/schema.ts — Drizzle schema +│ ├── db/schema.ts — Drizzle schema (terms, translations, auth tables) │ ├── models/termModel.ts — getGameTerms(), getDistractors() │ ├── seeding-datafiles.ts — seeds terms + translations from JSON │ ├── seeding-cefr-levels.ts — enriches translations with CEFR data @@ -139,7 +153,9 @@ vocab-trainer/ │ └── index.ts ├── scripts/ — Python extraction/comparison/merge scripts ├── documentation/ — project docs -├── docker-compose.yml +├── docker-compose.yml — local dev stack +├── docker-compose.prod.yml — production config reference +├── Caddyfile — reverse proxy routing └── pnpm-workspace.yaml ``` @@ -178,13 +194,28 @@ HTTP Request **Key principle:** all database code lives in `packages/db`. `apps/api` never imports `drizzle-orm` for queries — it only calls functions exported from `packages/db`. +### Production Infrastructure + +```text +Internet → Caddy (HTTPS termination) + ├── lilastudy.com → web container (nginx, static files) + ├── api.lilastudy.com → api container (Express, port 3000) + └── git.lilastudy.com → forgejo container (git + registry, port 3000) + +SSH (port 2222) → forgejo container (git push/pull) +``` + +All containers communicate over an internal Docker network. Only Caddy (80/443) and Forgejo SSH (2222) are exposed to the internet. + --- ## 7. Data Model (Current State) Words are modelled as language-neutral concepts (terms) separate from learning curricula (decks). Adding a new language pair requires no schema changes — only new rows in `translations`, `decks`. -**Core tables:** `terms`, `translations`, `term_glosses`, `decks`, `deck_terms`, `categories`, `term_categories` +**Core tables:** `terms`, `translations`, `term_glosses`, `decks`, `deck_terms`, `topics`, `term_topics` + +**Auth tables (managed by Better Auth):** `user`, `session`, `account`, `verification` Key columns on `terms`: `id` (uuid), `pos` (CHECK-constrained), `source`, `source_id` (unique pair for idempotent imports) @@ -201,9 +232,10 @@ Full schema is in `packages/db/src/db/schema.ts`. ### Endpoints ```text -POST /api/v1/game/start GameRequest → GameSession -POST /api/v1/game/answer AnswerSubmission → AnswerResult -GET /api/v1/health Health check +POST /api/v1/game/start GameRequest → GameSession (requires auth) +POST /api/v1/game/answer AnswerSubmission → AnswerResult (requires auth) +GET /api/v1/health Health check (public) +ALL /api/auth/* Better Auth handlers (public) ``` ### Schemas (packages/shared) @@ -235,7 +267,7 @@ Typed error classes (`AppError` base, `ValidationError` 400, `NotFoundError` 404 - **Session length**: 3 or 10 questions (configurable) - **Scoring**: +1 per correct answer (no speed bonus for MVP) - **Timer**: none in singleplayer MVP -- **No auth required**: anonymous users +- **Auth required**: users must log in via Google or GitHub - **Submit-before-send**: user selects, then confirms (prevents misclicks) --- @@ -258,14 +290,15 @@ After completing a task: share the code, ask what to refactor and why. The LLM s ## 11. Post-MVP Ladder -| Phase | What it adds | -| ----------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------- | -| Auth | Auth | Better Auth (Google + GitHub), embedded in Express API, user rows in DB | -| User Stats | Games played, score history, profile page | -| Multiplayer Lobby | Room creation, join by code, WebSocket connection | -| Multiplayer Game | Simultaneous answers, server timer, live scores, winner screen | -| Deployment | Docker Compose prod config, Nginx, Let's Encrypt, Hetzner VPS | -| Hardening | Rate limiting, error boundaries, CI/CD, DB backups | +| Phase | What it adds | Status | +| ----------------- | ------------------------------------------------------------------------------- | ------ | +| Auth | Better Auth (Google + GitHub), embedded in Express API, user rows in DB | ✅ | +| Deployment | Docker Compose, Caddy, Forgejo, CI/CD, Hetzner VPS | ✅ | +| Hardening (partial) | CI/CD pipeline, DB backups | ✅ | +| User Stats | Games played, score history, profile page | ❌ | +| Multiplayer Lobby | Room creation, join by code, WebSocket connection | ❌ | +| Multiplayer Game | Simultaneous answers, server timer, live scores, winner screen | ❌ | +| Hardening (rest) | Rate limiting, error boundaries, monitoring, accessibility | ❌ | ### Future Data Model Extensions (deferred, additive) @@ -285,11 +318,16 @@ All are new tables referencing existing `terms` rows via FK. No existing schema - Game mechanic: simultaneous answers, 15-second server timer, all players see same question - Valkey for ephemeral room state, PostgreSQL for durable records -### Infrastructure (deferred) +### Infrastructure (current) -- `app.yourdomain.com` → React frontend -- `api.yourdomain.com` → Express API + WebSocket + Better Auth -- Docker Compose with `nginx-proxy` + `acme-companion` for automatic SSL +- `lilastudy.com` → React frontend (nginx serving static files) +- `api.lilastudy.com` → Express API + Better Auth +- `git.lilastudy.com` → Forgejo (git server + container registry) +- Docker Compose with Caddy for automatic HTTPS via Let's Encrypt +- CI/CD via Forgejo Actions (build on push to main, deploy via SSH) +- Daily DB backups with cron, synced to dev laptop + +See `deployment.md` for full infrastructure documentation. --- @@ -312,14 +350,14 @@ See `roadmap.md` for the full roadmap with task-level checkboxes. ### Dependency Graph ```text -Phase 0 (Foundation) -└── Phase 1 (Vocabulary Data + API) - └── Phase 2 (Singleplayer UI) - └── Phase 3 (Auth) - ├── Phase 4 (Room Lobby) - │ └── Phase 5 (Multiplayer Game) - │ └── Phase 6 (Deployment) - └── Phase 7 (Hardening) +Phase 0 (Foundation) ✅ +└── Phase 1 (Vocabulary Data + API) ✅ + └── Phase 2 (Singleplayer UI) ✅ + ├── Phase 3 (Auth) ✅ + │ └── Phase 6 (Deployment + CI/CD) ✅ + └── Phase 4 (Multiplayer Lobby) + └── Phase 5 (Multiplayer Game) + └── Phase 7 (Hardening) ``` --- From 4c48859d0063f808aaeb1672873a15af72b0bbe4 Mon Sep 17 00:00:00 2001 From: lila Date: Sun, 19 Apr 2026 09:31:01 +0200 Subject: [PATCH 05/13] updating docs --- documentation/roadmap.md | 51 ++++--- documentation/spec.md | 10 +- scripts/create-issues.sh | 280 --------------------------------------- 3 files changed, 30 insertions(+), 311 deletions(-) delete mode 100644 scripts/create-issues.sh diff --git a/documentation/roadmap.md b/documentation/roadmap.md index 7c4b4ed..ce35ecd 100644 --- a/documentation/roadmap.md +++ b/documentation/roadmap.md @@ -176,37 +176,36 @@ _Note: Deployment was moved ahead of multiplayer — the app is useful without m **Goal:** Players can create and join rooms; the host sees all joined players in real time. **Done when:** Two browser tabs can join the same room and see each other's display names update live via WebSocket. -- [ ] Write Drizzle schema: `rooms`, `room_players` -- [ ] Write and run migration -- [ ] `POST /rooms` and `POST /rooms/:code/join` REST endpoints -- [ ] `RoomService`: create room with short code, join room, enforce max player limit -- [ ] WebSocket server: attach `ws` upgrade handler to Express HTTP server -- [ ] WS auth middleware: validate JWT on upgrade -- [ ] WS message router: dispatch by `type` -- [ ] `room:join` / `room:leave` handlers → broadcast `room:state` -- [ ] Room membership tracked in Valkey (ephemeral) + PostgreSQL (durable) -- [ ] Define all WS event Zod schemas in `packages/shared` -- [ ] Frontend: `/multiplayer/lobby` — create room + join-by-code -- [ ] Frontend: `/multiplayer/room/:code` — player list, room code, "Start Game" (host only) -- [ ] Frontend: WS client singleton with reconnect +- [x] Write Drizzle schema: `lobbies`, `lobby_players` +- [x] Write and run migration +- [x] `POST /api/v1/lobbies` and `POST /api/v1/lobbies/:code/join` REST endpoints +- [x] `LobbyService`: create lobby with Crockford Base32 code, join lobby, enforce max player limit +- [x] WebSocket server: attach `ws` upgrade handler to Express HTTP server +- [x] WS auth middleware: validate Better Auth session on upgrade +- [x] WS message router: dispatch by `type` via Zod discriminated union +- [x] `lobby:join` / `lobby:leave` handlers → broadcast `lobby:state` +- [x] Lobby membership tracked in PostgreSQL (durable), game state in-memory (Valkey deferred) +- [x] Define all WS event Zod schemas in `packages/shared` +- [x] Frontend: `/multiplayer` — create lobby + join-by-code +- [x] Frontend: `/multiplayer/lobby/:code` — player list, lobby code, "Start Game" (host only) +- [x] Frontend: WS client class with typed message handlers --- ## Phase 5 — Multiplayer Game **Goal:** Host starts a game; all players answer simultaneously in real time; a winner is declared. -**Done when:** 2–4 players complete a 10-round game with correct live scores and a winner screen. +**Done when:** 2–4 players complete a 3-round game with correct live scores and a winner screen. -- [ ] `GameService`: generate question sequence, enforce 15s server timer -- [ ] `room:start` WS handler → broadcast first `game:question` -- [ ] `game:answer` WS handler → collect per-player answers -- [ ] On all-answered or timeout → evaluate, broadcast `game:answer_result` -- [ ] After N rounds → broadcast `game:finished`, update DB (transactional) -- [ ] Frontend: `/multiplayer/game/:code` route -- [ ] Frontend: reuse `QuestionCard` + `OptionButton`; add countdown timer -- [ ] Frontend: `ScoreBoard` component — live per-player scores -- [ ] Frontend: `GameFinished` screen — winner highlight, final scores, play again -- [ ] Unit tests for `GameService` (round evaluation, tie-breaking, timeout) +- [x] `MultiplayerGameService`: generate question sequence, enforce 15s server timer +- [x] `lobby:start` WS handler → broadcast first `game:question` +- [x] `game:answer` WS handler → collect per-player answers +- [x] On all-answered or timeout → evaluate, broadcast `game:answer_result` +- [x] After N rounds → broadcast `game:finished`, update DB (transactional) +- [x] Frontend: `/multiplayer/game/:code` route +- [x] Frontend: reuse `QuestionCard` + `OptionButton`; round results per player +- [x] Frontend: `MultiplayerScoreScreen` — winner highlight, final scores, play again +- [x] Unit tests for `LobbyService`, WS auth, WS router --- @@ -236,7 +235,7 @@ Phase 0 (Foundation) ✅ └── Phase 2 (Singleplayer UI) ✅ ├── Phase 3 (Auth) ✅ │ └── Phase 6 (Deployment + CI/CD) ✅ - └── Phase 4 (Multiplayer Lobby) - └── Phase 5 (Multiplayer Game) + └── Phase 4 (Multiplayer Lobby) ✅ + └── Phase 5 (Multiplayer Game) ✅ └── Phase 7 (Hardening) ``` diff --git a/documentation/spec.md b/documentation/spec.md index 637da00..d2d320f 100644 --- a/documentation/spec.md +++ b/documentation/spec.md @@ -80,7 +80,7 @@ The monorepo structure and tooling are already set up. This is the full stack. | Auth | Better Auth (Google + GitHub) | ✅ | | Deployment | Docker Compose, Caddy, Hetzner | ✅ | | CI/CD | Forgejo Actions | ✅ | -| Realtime | WebSockets (`ws` library) | ❌ post-MVP | +| Realtime | WebSockets (`ws` library) | ✅ | | Cache | Valkey | ❌ post-MVP | --- @@ -296,8 +296,8 @@ After completing a task: share the code, ask what to refactor and why. The LLM s | Deployment | Docker Compose, Caddy, Forgejo, CI/CD, Hetzner VPS | ✅ | | Hardening (partial) | CI/CD pipeline, DB backups | ✅ | | User Stats | Games played, score history, profile page | ❌ | -| Multiplayer Lobby | Room creation, join by code, WebSocket connection | ❌ | -| Multiplayer Game | Simultaneous answers, server timer, live scores, winner screen | ❌ | +| Multiplayer Lobby | Room creation, join by code, WebSocket connection | ✅ | +| Multiplayer Game | Simultaneous answers, server timer, live scores, winner screen | ✅ | | Hardening (rest) | Rate limiting, error boundaries, monitoring, accessibility | ❌ | ### Future Data Model Extensions (deferred, additive) @@ -355,8 +355,8 @@ Phase 0 (Foundation) ✅ └── Phase 2 (Singleplayer UI) ✅ ├── Phase 3 (Auth) ✅ │ └── Phase 6 (Deployment + CI/CD) ✅ - └── Phase 4 (Multiplayer Lobby) - └── Phase 5 (Multiplayer Game) + └── Phase 4 (Multiplayer Lobby) ✅ + └── Phase 5 (Multiplayer Game) ✅ └── Phase 7 (Hardening) ``` diff --git a/scripts/create-issues.sh b/scripts/create-issues.sh deleted file mode 100644 index fefb072..0000000 --- a/scripts/create-issues.sh +++ /dev/null @@ -1,280 +0,0 @@ -#!/bin/bash - -# Forgejo batch issue creator for lila -# Usage: FORGEJO_TOKEN=your_token ./create-issues.sh - -FORGEJO_URL="https://git.lilastudy.com" -OWNER="forgejo-lila" -REPO="lila" -TOKEN="${FORGEJO_TOKEN:?Set FORGEJO_TOKEN environment variable}" - -API="${FORGEJO_URL}/api/v1/repos/${OWNER}/${REPO}" - -# Helper: create a label (ignores if already exists) -create_label() { - local name="$1" color="$2" description="$3" - curl -s -X POST "${API}/labels" \ - -H "Authorization: token ${TOKEN}" \ - -H "Content-Type: application/json" \ - -d "{\"name\":\"${name}\",\"color\":\"${color}\",\"description\":\"${description}\"}" > /dev/null - echo "Label: ${name}" -} - -# Helper: create an issue with labels -create_issue() { - local title="$1" body="$2" - shift 2 - local labels="$*" - - # Build labels JSON array - local label_ids="" - for label in $labels; do - local id - id=$(curl -s "${API}/labels" \ - -H "Authorization: token ${TOKEN}" | \ - python3 -c "import sys,json; [print(l['id']) for l in json.load(sys.stdin) if l['name']=='${label}']") - if [ -n "$label_ids" ]; then - label_ids="${label_ids},${id}" - else - label_ids="${id}" - fi - done - - curl -s -X POST "${API}/issues" \ - -H "Authorization: token ${TOKEN}" \ - -H "Content-Type: application/json" \ - -d "{\"title\":$(echo "$title" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))'),\"body\":$(echo "$body" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))'),\"labels\":[${label_ids}]}" > /dev/null - - echo "Issue: ${title}" -} - -echo "=== Creating labels ===" -create_label "feature" "#0075ca" "New user-facing functionality" -create_label "infra" "#e4e669" "Infrastructure, deployment, DevOps" -create_label "debt" "#d876e3" "Technical cleanup, refactoring" -create_label "security" "#b60205" "Security improvements" -create_label "ux" "#1d76db" "User experience, accessibility, polish" -create_label "multiplayer" "#0e8a16" "Multiplayer lobby and game features" - -echo "" -echo "=== Creating issues ===" - -# ── feature ── - -create_issue \ - "Add guest/try-now option — play without account" \ - "Allow users to play a quiz without signing in so they can see what the app offers before creating an account. Make auth middleware optional on game routes, add a 'Try without account' button on the login/landing page." \ - feature - -create_issue \ - "Add Apple login provider" \ - "Add Apple as a social login option via Better Auth. Requires Apple Developer account and Sign in with Apple configuration." \ - feature - -create_issue \ - "Add email+password login" \ - "Add traditional email and password authentication as an alternative to social login. Configure via Better Auth." \ - feature - -create_issue \ - "User stats endpoint + profile page" \ - "Add GET /users/me/stats endpoint returning games played, score history, etc. Build a frontend profile page displaying the stats." \ - feature - -# ── infra ── - -create_issue \ - "Google OAuth app verification and publishing" \ - "Currently only test users can log in via Google. Publish the OAuth consent screen so any Google user can sign in. Requires branding verification through Google Cloud Console." \ - infra - -create_issue \ - "Set up Docker credential helper on dev laptop" \ - "Docker credentials are stored unencrypted in ~/.docker/config.json. Set up a credential helper to store them securely. See https://docs.docker.com/go/credential-store/" \ - infra - -create_issue \ - "VPS monitoring and logging" \ - "Set up monitoring and centralized logging on the VPS. Options: chkrootkit/rkhunter for security, logwatch/monit for daily summaries, uptime monitoring for service health." \ - infra - -create_issue \ - "Move to offsite backup storage" \ - "Currently database backups live on the same VPS. Add offsite copies to Hetzner Object Storage or similar S3-compatible service to protect against VPS failure." \ - infra - -create_issue \ - "Replace in-memory game session store with Valkey" \ - "Add Valkey container to the production Docker stack. Implement ValkeyGameSessionStore using the existing GameSessionStore interface. Required before multiplayer." \ - infra - -create_issue \ - "Modern env management approach" \ - "Evaluate replacing .env files with a more robust approach (e.g. dotenvx, infisical, or similar). Current setup works but .env files are error-prone and not versioned." \ - infra - -create_issue \ - "Pin dependencies in package.json files" \ - "Pin all dependency versions in package.json files to exact versions to prevent unexpected updates from breaking builds." \ - infra - -# ── debt ── - -create_issue \ - "Rethink organization of datafiles and wordlists" \ - "The current layout of data-sources/, scripts/datafiles/, scripts/data-sources/, and packages/db/src/data/ is confusing with overlapping content. Consolidate into a clear structure." \ - debt - -create_issue \ - "Resolve eslint peer dependency warning" \ - "eslint-plugin-react-hooks 7.0.1 expects eslint ^3.0.0-^9.0.0 but found 10.0.3. Resolve the peer dependency mismatch." \ - debt - -# ── security ── - -create_issue \ - "Rate limiting on API endpoints" \ - "Add rate limiting to prevent abuse. At minimum: auth endpoints (brute force prevention), game endpoints (spam prevention). Consider express-rate-limit or similar." \ - security - -# ── ux ── - -create_issue \ - "404/redirect handling for unknown routes and subdomains" \ - "Unknown routes return raw errors. Add a catch-all route on the frontend for client-side 404s. Consider Caddy fallback for unrecognized subdomains." \ - ux - -create_issue \ - "React error boundaries" \ - "Add error boundaries to catch and display runtime errors gracefully instead of crashing the entire app." \ - ux - -create_issue \ - "Accessibility pass" \ - "Keyboard navigation for quiz buttons, ARIA labels on interactive elements, focus management during quiz flow." \ - ux - -create_issue \ - "Favicon, page titles, Open Graph meta" \ - "Add favicon, set proper page titles per route, add Open Graph meta tags for link previews when sharing." \ - ux - -# ── multiplayer ── - -create_issue \ - "Drizzle schema: lobbies, lobby_players + migration" \ - "Create lobbies table (id, code, host_user_id, status, is_private, game_mode, settings, created_at) and lobby_players table (lobby_id, user_id, score, joined_at). Run migration. See game-modes.md for game_mode values." \ - multiplayer - -create_issue \ - "REST endpoints: POST /lobbies, POST /lobbies/:code/join" \ - "Create lobby (generates short code, sets host) and join lobby (validates code, adds player, enforces max limit)." \ - multiplayer - -create_issue \ - "LobbyService: create lobby, join lobby, enforce player limit" \ - "Service layer for lobby management. Generate human-readable codes, validate join requests, track lobby state. Public lobbies are browsable, private lobbies require code." \ - multiplayer - -create_issue \ - "WebSocket server: attach ws upgrade to Express" \ - "Attach ws library upgrade handler to the existing Express HTTP server. Handle connection lifecycle." \ - multiplayer - -create_issue \ - "WS auth middleware: validate session on upgrade" \ - "Validate Better Auth session on WebSocket upgrade request. Reject unauthenticated connections." \ - multiplayer - -create_issue \ - "WS message router: dispatch by type" \ - "Route incoming WebSocket messages by their type field to the appropriate handler. Use Zod discriminated union for type safety." \ - multiplayer - -create_issue \ - "Lobby join/leave handlers + broadcast lobby state" \ - "Handle lobby:join and lobby:leave WebSocket events. Broadcast updated player list to all connected players in the lobby." \ - multiplayer - -create_issue \ - "Lobby state in Valkey (ephemeral) + PostgreSQL (durable)" \ - "Store live lobby state (connected players, current question, timer) in Valkey. Store durable records (who played, final scores) in PostgreSQL." \ - multiplayer - -create_issue \ - "WS event Zod schemas in packages/shared" \ - "Define all WebSocket message types as Zod discriminated unions in packages/shared. Covers lobby events (join, leave, start) and game events (question, answer, result, finished)." \ - multiplayer - -create_issue \ - "Frontend: lobby browser + create/join lobby" \ - "Lobby list showing public open lobbies. Create lobby form (game mode, public/private). Join-by-code input for private lobbies." \ - multiplayer - -create_issue \ - "Frontend: lobby view (player list, code, start game)" \ - "Show lobby code, connected players, game mode. Host sees Start Game button. Players see waiting state. Real-time updates via WebSocket." \ - multiplayer - -create_issue \ - "Frontend: WS client singleton with reconnect" \ - "WebSocket client that maintains a single connection, handles reconnection on disconnect, and dispatches incoming messages to the appropriate state handlers." \ - multiplayer - -create_issue \ - "GameService: question sequence + server timer" \ - "Generate question sequence for a lobby game. Enforce per-question timer (e.g. 15s). Timer logic varies by game mode — see game-modes.md." \ - multiplayer - -create_issue \ - "lobby:start WS handler — broadcast first question" \ - "When host starts the game, generate questions, change lobby status to in_progress, broadcast first question to all players." \ - multiplayer - -create_issue \ - "game:answer WS handler — collect answers" \ - "Receive player answers via WebSocket. Track who has answered. Behavior varies by game mode (simultaneous vs turn-based vs buzzer)." \ - multiplayer - -create_issue \ - "Answer evaluation + broadcast results" \ - "On all-answered or timeout: evaluate answers, calculate scores, broadcast game:answer_result to all players. Then send next question or end game." \ - multiplayer - -create_issue \ - "Game finished: broadcast results, update DB" \ - "After final round: broadcast game:finished with final scores and winner. Write game results to PostgreSQL (transactional). Change lobby status to finished." \ - multiplayer - -create_issue \ - "Frontend: multiplayer game route" \ - "Route for active multiplayer games. Receives questions and results via WebSocket. Reuses QuestionCard and OptionButton components." \ - multiplayer - -create_issue \ - "Frontend: countdown timer component" \ - "Visual countdown timer synchronized with server timer. Shows remaining seconds per question." \ - multiplayer - -create_issue \ - "Frontend: ScoreBoard component (live per-player scores)" \ - "Displays live scores for all players during a multiplayer game. Updates in real-time via WebSocket." \ - multiplayer - -create_issue \ - "Frontend: GameFinished screen" \ - "Winner highlight, final scores, play again option. Returns to lobby on play again." \ - multiplayer - -create_issue \ - "Multiplayer GameService unit tests" \ - "Unit tests for round evaluation, scoring, tie-breaking, timeout handling across different game modes." \ - multiplayer - -create_issue \ - "Graceful WS reconnect with exponential back-off" \ - "Handle WebSocket disconnections gracefully. Reconnect with exponential back-off. Restore game state on reconnection if game is still in progress." \ - multiplayer - -echo "" -echo "=== Done ===" From c866805c80e77ef675344d104374633401dcec22 Mon Sep 17 00:00:00 2001 From: lila Date: Sun, 19 Apr 2026 17:24:39 +0200 Subject: [PATCH 06/13] updating docs --- documentation/design.md | 5 +++++ documentation/notes.md | 1 + 2 files changed, 6 insertions(+) create mode 100644 documentation/design.md diff --git a/documentation/design.md b/documentation/design.md new file mode 100644 index 0000000..4d2f6fa --- /dev/null +++ b/documentation/design.md @@ -0,0 +1,5 @@ +# design + +## notes + +break points diff --git a/documentation/notes.md b/documentation/notes.md index c750683..efdedda 100644 --- a/documentation/notes.md +++ b/documentation/notes.md @@ -4,6 +4,7 @@ - pinning dependencies in package.json files - rethink organisation of datafiles and wordlists +- admin dashboard for user management, also overview of words and languages and all their stats ## problems+thoughts From 6dbc16f23d42acd697358918b4be1e786d24cb9f Mon Sep 17 00:00:00 2001 From: lila Date: Sun, 19 Apr 2026 17:27:16 +0200 Subject: [PATCH 07/13] style(global): add color variables with dark theme support --- apps/web/src/index.css | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index f1d8c73..65c98ab 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1 +1,28 @@ @import "tailwindcss"; + +:root { + --color-primary: #7c3aed; + --color-primary-light: #a78bfa; + --color-primary-dark: #5b21b6; + --color-accent: #ec4899; + --color-accent-light: #f9a8d4; + --color-accent-dark: #be185d; + --color-bg: #fafafa; + --color-surface: #f5f3ff; + --color-text: #1f1f2e; + --color-text-muted: #6b7280; +} + +[data-theme="dark"] { + --color-bg: #0f0e17; + --color-surface: #1a1730; + --color-text: #fffffe; + --color-text-muted: #a7a9be; +} + +@layer base { + body { + background-color: var(--color-bg); + color: var(--color-text); + } +} From 6c4ef371c1f8fa693b21022e7ca2c9756d4936b7 Mon Sep 17 00:00:00 2001 From: lila Date: Sun, 19 Apr 2026 17:51:43 +0200 Subject: [PATCH 08/13] feat(navbar): add modular navbar components and color variables --- apps/web/src/components/navbar/NavAuth.tsx | 40 +++++++++++++++ apps/web/src/components/navbar/NavBar.tsx | 18 +++++++ apps/web/src/components/navbar/NavLink.tsx | 26 ++++++++++ apps/web/src/components/navbar/NavLinks.tsx | 21 ++++++++ apps/web/src/components/navbar/NavLogin.tsx | 17 ++++++ apps/web/src/components/navbar/NavLogout.tsx | 26 ++++++++++ apps/web/src/routes/__root.tsx | 54 +++----------------- 7 files changed, 154 insertions(+), 48 deletions(-) create mode 100644 apps/web/src/components/navbar/NavAuth.tsx create mode 100644 apps/web/src/components/navbar/NavBar.tsx create mode 100644 apps/web/src/components/navbar/NavLink.tsx create mode 100644 apps/web/src/components/navbar/NavLinks.tsx create mode 100644 apps/web/src/components/navbar/NavLogin.tsx create mode 100644 apps/web/src/components/navbar/NavLogout.tsx diff --git a/apps/web/src/components/navbar/NavAuth.tsx b/apps/web/src/components/navbar/NavAuth.tsx new file mode 100644 index 0000000..22b8479 --- /dev/null +++ b/apps/web/src/components/navbar/NavAuth.tsx @@ -0,0 +1,40 @@ +import { Link, useNavigate } from "@tanstack/react-router"; +import { useSession, signOut } from "../../lib/auth-client"; + +const NavAuth = () => { + const { data: session } = useSession(); + const navigate = useNavigate(); + + const handleSignOut = () => { + void signOut() + .then(() => void navigate({ to: "/" })) + .catch((err) => console.error("Sign out error:", err)); + }; + + return ( +
+ {session ? ( + + ) : ( + + Sign in + + )} +
+ ); +}; + +export default NavAuth; diff --git a/apps/web/src/components/navbar/NavBar.tsx b/apps/web/src/components/navbar/NavBar.tsx new file mode 100644 index 0000000..b5bb494 --- /dev/null +++ b/apps/web/src/components/navbar/NavBar.tsx @@ -0,0 +1,18 @@ +import NavAuth from "./NavAuth"; +import NavLinks from "./NavLinks"; + +const Navbar = () => { + return ( +
+
+ + lila + + + +
+
+ ); +}; + +export default Navbar; diff --git a/apps/web/src/components/navbar/NavLink.tsx b/apps/web/src/components/navbar/NavLink.tsx new file mode 100644 index 0000000..c0dae7b --- /dev/null +++ b/apps/web/src/components/navbar/NavLink.tsx @@ -0,0 +1,26 @@ +import { Link } from "@tanstack/react-router"; + +type NavLinkProps = { to: string; children: React.ReactNode }; + +const NavLink = ({ to, children }: NavLinkProps) => { + return ( + + {children} + + ); +}; + +export default NavLink; diff --git a/apps/web/src/components/navbar/NavLinks.tsx b/apps/web/src/components/navbar/NavLinks.tsx new file mode 100644 index 0000000..5040c83 --- /dev/null +++ b/apps/web/src/components/navbar/NavLinks.tsx @@ -0,0 +1,21 @@ +import NavLink from "./NavLink"; + +const links = [ + { to: "/", label: "Home" }, + { to: "/play", label: "Play" }, + { to: "/multiplayer", label: "Multiplayer" }, +]; + +const NavLinks = () => { + return ( + + ); +}; + +export default NavLinks; diff --git a/apps/web/src/components/navbar/NavLogin.tsx b/apps/web/src/components/navbar/NavLogin.tsx new file mode 100644 index 0000000..2bbf39e --- /dev/null +++ b/apps/web/src/components/navbar/NavLogin.tsx @@ -0,0 +1,17 @@ +import { Link } from "@tanstack/react-router"; + +const NavLogin = () => { + return ( + + Sign in + + ); +}; + +export default NavLogin; diff --git a/apps/web/src/components/navbar/NavLogout.tsx b/apps/web/src/components/navbar/NavLogout.tsx new file mode 100644 index 0000000..ec297cf --- /dev/null +++ b/apps/web/src/components/navbar/NavLogout.tsx @@ -0,0 +1,26 @@ +import { useNavigate } from "@tanstack/react-router"; +import { signOut } from "../../lib/auth-client"; + +type NavLogoutProps = { name: string }; + +const NavLogout = ({ name }: NavLogoutProps) => { + const navigate = useNavigate(); + + const handleLogout = () => { + void signOut() + .then(() => void navigate({ to: "/" })) + .catch((err) => console.error("logout error:", err)); + }; + + return ( + + ); +}; + +export default NavLogout; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 0add685..1dc4378 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,56 +1,14 @@ -import { - createRootRoute, - Link, - Outlet, - useNavigate, -} from "@tanstack/react-router"; +import { createRootRoute, Outlet } from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; -import { useSession, signOut } from "../lib/auth-client"; +import Navbar from "../components/navbar/NavBar"; const RootLayout = () => { - const { data: session } = useSession(); - const navigate = useNavigate(); - return ( <> -
- - Home - - - Play - - - Multiplayer - -
- {session ? ( - - ) : ( - - Sign in - - )} -
-
-
- + +
+ +
); From 767970b6e665def192457844b83e42bebf962cc4 Mon Sep 17 00:00:00 2001 From: lila Date: Sun, 19 Apr 2026 17:57:47 +0200 Subject: [PATCH 09/13] renaming signin to login --- apps/web/src/components/navbar/NavLogin.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/navbar/NavLogin.tsx b/apps/web/src/components/navbar/NavLogin.tsx index 2bbf39e..f28bfdd 100644 --- a/apps/web/src/components/navbar/NavLogin.tsx +++ b/apps/web/src/components/navbar/NavLogin.tsx @@ -9,7 +9,7 @@ const NavLogin = () => { hover:bg-(--color-primary-dark) transition-colors duration-200" > - Sign in + Login ); }; From 4f514a4e9999d5cd7d84616adefc3f913a237b1b Mon Sep 17 00:00:00 2001 From: lila Date: Sun, 19 Apr 2026 18:24:42 +0200 Subject: [PATCH 10/13] feat(landing): add landing page with Hero, HowItWorks and FeatureCards --- .../src/components/landing/FeatureCards.tsx | 45 +++++++++++++ apps/web/src/components/landing/Hero.tsx | 64 +++++++++++++++++++ .../web/src/components/landing/HowItWorks.tsx | 48 ++++++++++++++ apps/web/src/routes/index.tsx | 9 ++- 4 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/landing/FeatureCards.tsx create mode 100644 apps/web/src/components/landing/Hero.tsx create mode 100644 apps/web/src/components/landing/HowItWorks.tsx diff --git a/apps/web/src/components/landing/FeatureCards.tsx b/apps/web/src/components/landing/FeatureCards.tsx new file mode 100644 index 0000000..849afd0 --- /dev/null +++ b/apps/web/src/components/landing/FeatureCards.tsx @@ -0,0 +1,45 @@ +const features = [ + { + emoji: "📱", + title: "Mobile-first", + description: "Designed for your thumb. Play on the go, anytime.", + }, + { + emoji: "🌍", + title: "5 languages", + description: + "English, Italian, German, French, Spanish — with more on the way.", + }, + { + emoji: "⚔️", + title: "Multiplayer coming", + description: "Challenge friends and see who has the bigger vocabulary.", + }, +]; + +const FeatureCards = () => { + return ( +
+

+ Why lila +

+ +
+ {features.map(({ emoji, title, description }) => ( +
+ {emoji} +

{title}

+

+ {description} +

+
+ ))} +
+
+ ); +}; + +export default FeatureCards; diff --git a/apps/web/src/components/landing/Hero.tsx b/apps/web/src/components/landing/Hero.tsx new file mode 100644 index 0000000..0297e53 --- /dev/null +++ b/apps/web/src/components/landing/Hero.tsx @@ -0,0 +1,64 @@ +import { Link } from "@tanstack/react-router"; +import { useSession } from "../../lib/auth-client"; + +const Hero = () => { + const { data: session } = useSession(); + + return ( +
+
+ + Vocabulary trainer + +
+ +

+ Meet{" "} + + lila + +

+ +

+ Learn words.{" "} + Beat friends. +

+ +
+ {["🇬🇧", "🇮🇹", "🇩🇪", "🇫🇷", "🇪🇸"].map((flag) => ( + + {flag} + + ))} +
+ +
+ {session ? ( + + Start playing → + + ) : ( + <> + + Get started → + + + Login + + + )} +
+
+ ); +}; + +export default Hero; diff --git a/apps/web/src/components/landing/HowItWorks.tsx b/apps/web/src/components/landing/HowItWorks.tsx new file mode 100644 index 0000000..b9791a8 --- /dev/null +++ b/apps/web/src/components/landing/HowItWorks.tsx @@ -0,0 +1,48 @@ +const steps = [ + { + number: "01", + title: "See a word", + description: + "A word appears in your target language, ready to challenge you.", + }, + { + number: "02", + title: "Pick the translation", + description: + "Choose from four options. Only one is correct — trust your gut.", + }, + { + number: "03", + title: "Track your score", + description: "See how you did and challenge a friend to beat it.", + }, +]; + +const HowItWorks = () => { + return ( +
+

+ How it works +

+ +
+ {steps.map(({ number, title, description }) => ( +
+ + {number} + +

{title}

+

+ {description} +

+
+ ))} +
+
+ ); +}; + +export default HowItWorks; diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index a17910a..2e9fd80 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,11 +1,16 @@ import { createFileRoute } from "@tanstack/react-router"; +import Hero from "../components/landing/Hero"; +import HowItWorks from "../components/landing/HowItWorks"; +import FeatureCards from "../components/landing/FeatureCards"; export const Route = createFileRoute("/")({ component: Index }); function Index() { return ( -
-

Welcome Home!

+
+ + +
); } From ef5c49f7cf98381e7902cc1289869dc2c7ac4980 Mon Sep 17 00:00:00 2001 From: lila Date: Sun, 19 Apr 2026 18:40:01 +0200 Subject: [PATCH 11/13] updating docs --- README.md | 169 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/README.md b/README.md index 675d039..32af038 100644 --- a/README.md +++ b/README.md @@ -1 +1,170 @@ # lila + +**Learn words. Beat friends.** + +lila is a vocabulary trainer built around a Duolingo-style quiz loop: a word appears in one language, you pick the correct translation from four choices. It supports singleplayer and real-time multiplayer, and is designed to work across multiple language pairs without schema changes. + +Live at [lilastudy.com](https://lilastudy.com). + +--- + +## Stack + +| Layer | Technology | +|---|---| +| Monorepo | pnpm workspaces | +| Frontend | React 18, Vite, TypeScript | +| Routing | TanStack Router | +| Server state | TanStack Query | +| Styling | Tailwind CSS | +| Backend | Node.js, Express, TypeScript | +| Database | PostgreSQL + Drizzle ORM | +| Validation | Zod (shared schemas) | +| Auth | Better Auth (Google + GitHub) | +| Realtime | WebSockets (`ws` library) | +| Testing | Vitest, supertest | +| Deployment | Docker Compose, Caddy, Hetzner VPS | +| CI/CD | Forgejo Actions | + +--- + +## Repository Structure + +``` +lila/ +├── apps/ +│ ├── api/ — Express backend +│ └── web/ — React frontend +├── packages/ +│ ├── shared/ — Zod schemas and types shared between frontend and backend +│ └── db/ — Drizzle schema, migrations, models, seeding scripts +├── scripts/ — Python scripts for vocabulary data extraction +└── documentation/ — Project docs +``` + +`packages/shared` is the contract between frontend and backend. All request/response shapes are defined there as Zod schemas and never duplicated. + +--- + +## Architecture + +Requests flow through a strict layered architecture: + +``` +HTTP Request → Router → Controller → Service → Model → Database +``` + +Each layer only talks to the layer directly below it. Controllers handle HTTP only. Services contain business logic only. Models contain database queries only. All database code lives in `packages/db` — the API never imports Drizzle directly for queries. + +--- + +## Data Model + +Words are modelled as language-neutral concepts (`terms`) with per-language `translations`. Adding a new language requires no schema changes — only new rows. CEFR levels (A1–C2) are stored per translation for difficulty filtering. + +Core tables: `terms`, `translations`, `term_glosses`, `decks`, `deck_terms` +Auth tables (managed by Better Auth): `user`, `session`, `account`, `verification` + +Vocabulary data is sourced from WordNet and the Open Multilingual Wordnet (OMW). + +--- + +## API + +``` +POST /api/v1/game/start — start a quiz session (auth required) +POST /api/v1/game/answer — submit an answer (auth required) +GET /api/v1/health — health check (public) +ALL /api/auth/* — Better Auth handlers (public) +``` + +The correct answer is never sent to the frontend — all evaluation happens server-side. + +--- + +## Multiplayer + +Rooms are created via REST, then managed over WebSockets. Messages are typed via a Zod discriminated union. The host starts the game; all players answer simultaneously with a 15-second server-enforced timer. Room state is held in-memory (Valkey deferred). + +--- + +## Infrastructure + +``` +Internet → Caddy (HTTPS) + ├── lilastudy.com → web (nginx, static files) + ├── api.lilastudy.com → api (Express) + └── git.lilastudy.com → Forgejo (git + registry) +``` + +Deployed on a Hetzner VPS (Debian 13, ARM64). Images are built cross-compiled for ARM64 and pushed to the Forgejo container registry. CI/CD runs via Forgejo Actions on push to `main`. Daily database backups are synced to the dev laptop via rsync. + +See `documentation/deployment.md` for the full infrastructure setup. + +--- + +## Local Development + +### Prerequisites + +- Node.js 20+ +- pnpm 9+ +- Docker + Docker Compose + +### Setup + +```bash +# Install dependencies +pnpm install + +# Create your local env file (used by docker compose + the API) +cp .env.example .env + +# Start local services (PostgreSQL, Valkey) +docker compose up -d + +# Build shared packages +pnpm --filter @lila/shared build +pnpm --filter @lila/db build + +# Run migrations and seed data +pnpm --filter @lila/db migrate +pnpm --filter @lila/db seed + +# Start dev servers +pnpm dev +``` + +The API runs on `http://localhost:3000` and the frontend on `http://localhost:5173`. + +--- + +## Testing + +```bash +# All tests +pnpm test + +# API only +pnpm --filter api test + +# Frontend only +pnpm --filter web test +``` + +--- + +## Roadmap + +| Phase | Description | Status | +|---|---|---| +| 0 | Foundation — monorepo, tooling, dev environment | ✅ | +| 1 | Vocabulary data pipeline + REST API | ✅ | +| 2 | Singleplayer quiz UI | ✅ | +| 3 | Auth (Google + GitHub) | ✅ | +| 4 | Multiplayer lobby (WebSockets) | ✅ | +| 5 | Multiplayer game (real-time, server timer) | ✅ | +| 6 | Production deployment + CI/CD | ✅ | +| 7 | Hardening (rate limiting, error boundaries, monitoring, accessibility) | 🔄 | + +See `documentation/roadmap.md` for task-level detail. From d033a08d877132d625e27a987efff106a1ee6cb6 Mon Sep 17 00:00:00 2001 From: lila Date: Sun, 19 Apr 2026 18:48:20 +0200 Subject: [PATCH 12/13] updating docs --- documentation/roadmap.md | 2 +- documentation/spec.md | 42 ++++++++++++++++++++++++---------------- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/documentation/roadmap.md b/documentation/roadmap.md index ce35ecd..ffb15c6 100644 --- a/documentation/roadmap.md +++ b/documentation/roadmap.md @@ -18,7 +18,7 @@ Each phase produces a working increment. Nothing is built speculatively. - [x] Configure Drizzle ORM + connection to local PostgreSQL - [x] Write first migration (empty — validates the pipeline works) - [x] `docker-compose.yml` for local dev: `api`, `web`, `postgres`, `valkey` -- [x] `.env.example` files for `apps/api` and `apps/web` +- [x] Root `.env.example` for local dev (`docker-compose.yml` + API) --- diff --git a/documentation/spec.md b/documentation/spec.md index d2d320f..37d8636 100644 --- a/documentation/spec.md +++ b/documentation/spec.md @@ -7,7 +7,7 @@ ## 1. Project Overview -A vocabulary trainer for English–Italian words. The quiz format is Duolingo-style: one word is shown as a prompt, and the user picks the correct translation from four choices (1 correct + 3 distractors of the same part-of-speech). The long-term vision is a multiplayer competitive game, but the MVP is a polished singleplayer experience. +A vocabulary trainer for English–Italian words. The quiz format is Duolingo-style: one word is shown as a prompt, and the user picks the correct translation from four choices (1 correct + 3 distractors of the same part-of-speech). The app supports both singleplayer and real-time multiplayer game modes. **The core learning loop:** Show word → pick answer → see result → next word → final score @@ -29,13 +29,13 @@ The vocabulary data comes from WordNet + the Open Multilingual Wordnet (OMW). A - Multiplayer mode: create a room, share a code, 2–4 players answer simultaneously in real time, live scores, winner screen - 1000+ English–Italian nouns seeded from WordNet -This is the full vision. The MVP deliberately ignores most of it. +This is the full vision. The current implementation already covers most of it; remaining items are captured in the roadmap and the Post-MVP ladder below. --- ## 3. MVP Scope -**Goal:** A working, presentable singleplayer quiz that can be shown to real people. +**Goal:** A working, presentable vocabulary trainer that can be shown to real people (singleplayer and multiplayer), with a production deployment. ### What is IN the MVP @@ -45,16 +45,14 @@ This is the full vision. The MVP deliberately ignores most of it. - Clean, mobile-friendly UI (Tailwind + shadcn/ui) - Global error handler with typed error classes - Unit + integration tests for the API -- Local dev only (no deployment for MVP) +- Authentication via Better Auth (Google + GitHub) +- Multiplayer lobby + game over WebSockets +- Production deployment (Docker Compose + Caddy + Hetzner) and CI/CD (Forgejo Actions) ### What is CUT from the MVP | Feature | Why cut | | ------------------------------- | -------------------------------------- | -| Authentication (Better Auth) | No user accounts needed for a demo | -| Multiplayer (WebSockets, rooms) | Core quiz works without it | -| Valkey / Redis cache | Only needed for multiplayer room state | -| Deployment to Hetzner | Ship to people locally first | | User stats / profiles | Needs auth | These are not deleted from the plan — they are deferred. The architecture is already designed to support them. See Section 11 (Post-MVP Ladder). @@ -81,14 +79,14 @@ The monorepo structure and tooling are already set up. This is the full stack. | Deployment | Docker Compose, Caddy, Hetzner | ✅ | | CI/CD | Forgejo Actions | ✅ | | Realtime | WebSockets (`ws` library) | ✅ | -| Cache | Valkey | ❌ post-MVP | +| Cache | Valkey | ⚠️ optional (used locally; production/state hardening) | --- ## 5. Repository Structure ```text -vocab-trainer/ +lila/ ├── .forgejo/ │ └── workflows/ │ └── deploy.yml — CI/CD pipeline (build, push, deploy) @@ -154,7 +152,6 @@ vocab-trainer/ ├── scripts/ — Python extraction/comparison/merge scripts ├── documentation/ — project docs ├── docker-compose.yml — local dev stack -├── docker-compose.prod.yml — production config reference ├── Caddyfile — reverse proxy routing └── pnpm-workspace.yaml ``` @@ -311,12 +308,20 @@ After completing a task: share the code, ask what to refactor and why. The LLM s All are new tables referencing existing `terms` rows via FK. No existing schema changes required. -### Multiplayer Architecture (deferred) +### Multiplayer Architecture (current + deferred) -- WebSocket protocol: `ws` library, Zod discriminated union for message types -- Room model: human-readable codes (e.g. `WOLF-42`), not matchmaking queue -- Game mechanic: simultaneous answers, 15-second server timer, all players see same question -- Valkey for ephemeral room state, PostgreSQL for durable records +**Implemented now:** + +- WebSocket protocol uses the `ws` library with a Zod discriminated union for message types (defined in `packages/shared`) +- Room model uses human-readable codes (no matchmaking queue) +- Lobby flow (create/join/leave) is real-time over WS, backed by PostgreSQL for durable membership/state +- Multiplayer game flow is real-time: host starts, all players see the same question, answers are collected simultaneously, with a server-enforced 15s timer and live scoring +- WebSocket connections are authenticated (Better Auth session validation on upgrade) + +**Deferred / hardening:** + +- Valkey-backed ephemeral state (room/game/session store) where in-memory state becomes a bottleneck +- Graceful reconnect/resume flows and more robust failure handling (tracked in Phase 7) ### Infrastructure (current) @@ -331,7 +336,7 @@ See `deployment.md` for full infrastructure documentation. --- -## 12. Definition of Done (MVP) +## 12. Definition of Done (Current Baseline) - [x] API returns quiz terms with correct distractors - [x] User can complete a quiz without errors @@ -340,6 +345,9 @@ See `deployment.md` for full infrastructure documentation. - [x] No hardcoded data — everything comes from the database - [x] Global error handler with typed error classes - [x] Unit + integration tests for API +- [x] Auth works end-to-end (Google + GitHub via Better Auth) +- [x] Multiplayer works end-to-end (lobby + real-time game over WebSockets) +- [x] Production deployment is live behind HTTPS (Caddy) with CI/CD deploys via Forgejo Actions --- From 0a0bafa0ec2072c7058c074852f276d3e4d213c3 Mon Sep 17 00:00:00 2001 From: lila Date: Sun, 19 Apr 2026 19:25:55 +0200 Subject: [PATCH 13/13] complete design overhaul --- apps/web/src/components/game/GameSetup.tsx | 28 +-- apps/web/src/components/game/OptionButton.tsx | 29 ++- apps/web/src/components/game/QuestionCard.tsx | 32 ++-- apps/web/src/components/game/ScoreScreen.tsx | 31 ++-- .../src/components/landing/FeatureCards.tsx | 45 +++-- apps/web/src/components/landing/Hero.tsx | 167 +++++++++++++----- .../web/src/components/landing/HowItWorks.tsx | 67 +++++-- .../multiplayer/MultiplayerScoreScreen.tsx | 43 +++-- apps/web/src/components/ui/ConfettiBurst.tsx | 103 +++++++++++ apps/web/src/index.css | 62 +++++++ apps/web/src/routes/login.tsx | 4 +- .../web/src/routes/multiplayer/game.$code.tsx | 14 +- apps/web/src/routes/multiplayer/index.tsx | 20 +-- .../src/routes/multiplayer/lobby.$code.tsx | 20 ++- 14 files changed, 505 insertions(+), 160 deletions(-) create mode 100644 apps/web/src/components/ui/ConfettiBurst.tsx diff --git a/apps/web/src/components/game/GameSetup.tsx b/apps/web/src/components/game/GameSetup.tsx index 0266342..9315bc4 100644 --- a/apps/web/src/components/game/GameSetup.tsx +++ b/apps/web/src/components/game/GameSetup.tsx @@ -35,16 +35,18 @@ const SettingGroup = ({ onSelect, }: SettingGroupProps) => (
-

{label}

+

+ {label} +

{options.map((option) => (
); diff --git a/apps/web/src/components/game/OptionButton.tsx b/apps/web/src/components/game/OptionButton.tsx index e01e4ae..783d25d 100644 --- a/apps/web/src/components/game/OptionButton.tsx +++ b/apps/web/src/components/game/OptionButton.tsx @@ -6,26 +6,39 @@ type OptionButtonProps = { export const OptionButton = ({ text, state, onSelect }: OptionButtonProps) => { const base = - "w-full py-3 px-6 rounded-2xl text-lg font-semibold transition-all duration-200 border-b-4 cursor-pointer"; + "group relative w-full overflow-hidden py-3 px-6 rounded-2xl text-lg font-semibold transition-all duration-200 border cursor-pointer text-left"; const styles = { - idle: "bg-white text-purple-900 border-purple-200 hover:bg-purple-50 hover:border-purple-300 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2", + idle: "bg-white text-(--color-primary-dark) border-(--color-primary-light) hover:bg-(--color-surface) hover:-translate-y-0.5 active:translate-y-0", selected: - "bg-purple-100 text-purple-900 border-purple-400 ring-2 ring-purple-400", - disabled: "bg-gray-100 text-gray-400 border-gray-200 cursor-default", - correct: "bg-emerald-400 text-white border-emerald-600 scale-[1.02]", - wrong: "bg-pink-400 text-white border-pink-600", + "bg-(--color-surface) text-(--color-primary-dark) border-(--color-primary) ring-2 ring-(--color-primary)", + disabled: + "bg-(--color-surface) text-(--color-primary-light) border-(--color-primary-light) cursor-default", + correct: + "bg-emerald-400/90 text-white border-emerald-600 ring-2 ring-emerald-300 scale-[1.01]", + wrong: "bg-pink-500/90 text-white border-pink-700 ring-2 ring-pink-300", }; + const motion = + state === "correct" ? "lila-pop" : state === "wrong" ? "lila-shake" : ""; + return ( ); }; diff --git a/apps/web/src/components/game/QuestionCard.tsx b/apps/web/src/components/game/QuestionCard.tsx index f81ec0d..7878a5b 100644 --- a/apps/web/src/components/game/QuestionCard.tsx +++ b/apps/web/src/components/game/QuestionCard.tsx @@ -48,22 +48,31 @@ export const QuestionCard = ({ return (
-
- - {questionNumber} / {totalQuestions} - +
+
+ Round {questionNumber}/{totalQuestions} +
+
+ {currentResult ? "Checked" : selectedOptionId !== null ? "Ready" : "Pick one"} +
-
-

+
+
+
+ +

{question.prompt}

{question.gloss && ( -

{question.gloss}

+

+ {question.gloss} +

)}
-
+
+
{question.options.map((option) => ( handleSelect(option.optionId)} /> ))} +
{!currentResult && selectedOptionId !== null && ( )} {currentResult && ( diff --git a/apps/web/src/components/game/ScoreScreen.tsx b/apps/web/src/components/game/ScoreScreen.tsx index afd3295..ac64da2 100644 --- a/apps/web/src/components/game/ScoreScreen.tsx +++ b/apps/web/src/components/game/ScoreScreen.tsx @@ -1,4 +1,5 @@ import type { AnswerResult } from "@lila/shared"; +import { ConfettiBurst } from "../ui/ConfettiBurst"; type ScoreScreenProps = { results: AnswerResult[]; onPlayAgain: () => void }; @@ -17,30 +18,38 @@ export const ScoreScreen = ({ results, onPlayAgain }: ScoreScreenProps) => { return (
-
-

Your Score

-

+
+ {percentage === 100 && } +
+
+ +

+ Results +

+

{score}/{total}

-

{getMessage()}

+

{getMessage()}

-
+
-

{percentage}% correct

+

+ {percentage}% correct +

{results.map((result, index) => (
{index + 1}. @@ -51,9 +60,9 @@ export const ScoreScreen = ({ results, onPlayAgain }: ScoreScreenProps) => {
); diff --git a/apps/web/src/components/landing/FeatureCards.tsx b/apps/web/src/components/landing/FeatureCards.tsx index 849afd0..e8085c2 100644 --- a/apps/web/src/components/landing/FeatureCards.tsx +++ b/apps/web/src/components/landing/FeatureCards.tsx @@ -12,29 +12,52 @@ const features = [ }, { emoji: "⚔️", - title: "Multiplayer coming", - description: "Challenge friends and see who has the bigger vocabulary.", + title: "Real-time multiplayer", + description: "Create a room, share the code, and race to the best score.", }, ]; const FeatureCards = () => { return ( -
-

- Why lila -

+
+
+
+ Tiny rounds · big dopamine +
+

+ Why lila +

+

+ Built to be fast to start, satisfying to finish, and fun to repeat. +

+
-
+
{features.map(({ emoji, title, description }) => (
- {emoji} -

{title}

-

+

+
+
+ {emoji} +
+

{title}

+
+

{description}

+
+ + + Instant feedback + + + + Type-safe API + +
))}
diff --git a/apps/web/src/components/landing/Hero.tsx b/apps/web/src/components/landing/Hero.tsx index 0297e53..6a6de87 100644 --- a/apps/web/src/components/landing/Hero.tsx +++ b/apps/web/src/components/landing/Hero.tsx @@ -5,57 +5,132 @@ const Hero = () => { const { data: session } = useSession(); return ( -
-
- - Vocabulary trainer - +
+
+
+
-

- Meet{" "} - - lila - -

+
+
+
+ + Duolingo-style drills · real-time multiplayer + +
-

- Learn words.{" "} - Beat friends. -

+

+ Learn vocabulary fast,{" "} + + together + + . +

-
- {["🇬🇧", "🇮🇹", "🇩🇪", "🇫🇷", "🇪🇸"].map((flag) => ( - - {flag} - - ))} -
+

+ A word appears. You pick the translation. You score points. + Then you queue up a room and{" "} + beat friends{" "} + in real time. +

-
- {session ? ( - - Start playing → - - ) : ( - <> - - Get started → - - - Login - - - )} +
+ {["🇬🇧", "🇮🇹", "🇩🇪", "🇫🇷", "🇪🇸"].map((flag) => ( + + {flag} + + ))} + + Supported languages: English, Italian, German, French, Spanish + +
+ +
+ {session ? ( + <> + + Play solo + + + Play with friends + + + ) : ( + <> + + Get started + + + Log in + + + )} +
+
+ +
+
+
+
+
+
+
+
+
+ + Live preview + +
+ +
+

+ Translate +

+
+
+ finestra +
+
+ (noun) · A2 +
+
+ +
+ {["window", "forest", "river", "kitchen"].map((opt) => ( +
+ {opt} +
+ ))} +
+ +
+
+ Round 2/10 +
+
+ + Multiplayer room +
+
+
+
+
+
); diff --git a/apps/web/src/components/landing/HowItWorks.tsx b/apps/web/src/components/landing/HowItWorks.tsx index b9791a8..8255493 100644 --- a/apps/web/src/components/landing/HowItWorks.tsx +++ b/apps/web/src/components/landing/HowItWorks.tsx @@ -20,26 +20,55 @@ const steps = [ const HowItWorks = () => { return ( -
-

- How it works -

- -
- {steps.map(({ number, title, description }) => ( -
- - {number} - -

{title}

-

- {description} -

+
+
+
+
+
+
+ Quick · satisfying · replayable
- ))} +

+ How it works +

+

+ Short rounds, instant feedback, and just enough pressure to make the + words stick. +

+
+ +
    + {steps.map(({ number, title, description }) => ( +
  1. +
    +
    +
    +
    +
    + + {number} + +
    +
    +
    +

    + {title} +

    +

    + {description} +

    +
    + + Under 30 seconds +
    +
    +
    +
  2. + ))} +
); diff --git a/apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx b/apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx index 5e95588..f530db8 100644 --- a/apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx +++ b/apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx @@ -1,5 +1,6 @@ import { useNavigate } from "@tanstack/react-router"; import type { LobbyPlayer } from "@lila/shared"; +import { ConfettiBurst } from "../ui/ConfettiBurst"; type MultiplayerScoreScreenProps = { players: LobbyPlayer[]; @@ -26,19 +27,27 @@ export const MultiplayerScoreScreen = ({ .join(" and "); return ( -
-
+
+
+
+
+ +
+ {isWinner && !isTie && } {/* Result header */}
-

+
+ Multiplayer +
+

{isTie ? "It's a tie!" : isWinner ? "You win! 🎉" : "Game over"}

-

+

{isTie ? `${winnerNames} tied` : `${winnerNames} wins!`}

-
+
{/* Score list */}
@@ -48,35 +57,35 @@ export const MultiplayerScoreScreen = ({ return (
- + {index + 1}. {player.user.name} {isCurrentUser && ( - + (you) )} {isPlayerWinner && ( - + 👑 )}
- + {player.score} pts
@@ -84,12 +93,12 @@ export const MultiplayerScoreScreen = ({ })}
-
+
{/* Actions */}
-
+
{/* Join lobby */}
-

Join a lobby

-

+

Join a lobby

+

Enter the code shared by your host.

setJoinCode(e.target.value)} @@ -128,7 +128,7 @@ function MultiplayerPage() { disabled={isCreating || isJoining} /> -

Click to copy

+

Click to copy

-
+
{/* Player list */}
-

+

Players ({lobby.players.length})

    {lobby.players.map((player) => (
  • {player.user.name} @@ -135,7 +137,7 @@ function LobbyPage() { {/* Start button — host only */} {isHost && (