From 4c48859d0063f808aaeb1672873a15af72b0bbe4 Mon Sep 17 00:00:00 2001 From: lila Date: Sun, 19 Apr 2026 09:31:01 +0200 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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 && (