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