diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index a48cee1..306cc78 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -8,9 +8,6 @@ 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 diff --git a/README.md b/README.md index 32af038..675d039 100644 --- a/README.md +++ b/README.md @@ -1,170 +1 @@ # 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. diff --git a/apps/web/src/components/game/GameSetup.tsx b/apps/web/src/components/game/GameSetup.tsx index 9315bc4..0266342 100644 --- a/apps/web/src/components/game/GameSetup.tsx +++ b/apps/web/src/components/game/GameSetup.tsx @@ -35,18 +35,16 @@ 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 783d25d..e01e4ae 100644 --- a/apps/web/src/components/game/OptionButton.tsx +++ b/apps/web/src/components/game/OptionButton.tsx @@ -6,39 +6,26 @@ type OptionButtonProps = { export const OptionButton = ({ text, state, onSelect }: OptionButtonProps) => { const base = - "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"; + "w-full py-3 px-6 rounded-2xl text-lg font-semibold transition-all duration-200 border-b-4 cursor-pointer"; const styles = { - idle: "bg-white text-(--color-primary-dark) border-(--color-primary-light) hover:bg-(--color-surface) hover:-translate-y-0.5 active:translate-y-0", + 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", selected: - "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", + "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", }; - 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 7878a5b..f81ec0d 100644 --- a/apps/web/src/components/game/QuestionCard.tsx +++ b/apps/web/src/components/game/QuestionCard.tsx @@ -48,31 +48,22 @@ export const QuestionCard = ({ return (
-
-
- Round {questionNumber}/{totalQuestions} -
-
- {currentResult ? "Checked" : selectedOptionId !== null ? "Ready" : "Pick one"} -
+
+ + {questionNumber} / {totalQuestions} +
-
-
-
- -

+
+

{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 ac64da2..afd3295 100644 --- a/apps/web/src/components/game/ScoreScreen.tsx +++ b/apps/web/src/components/game/ScoreScreen.tsx @@ -1,5 +1,4 @@ import type { AnswerResult } from "@lila/shared"; -import { ConfettiBurst } from "../ui/ConfettiBurst"; type ScoreScreenProps = { results: AnswerResult[]; onPlayAgain: () => void }; @@ -18,38 +17,30 @@ export const ScoreScreen = ({ results, onPlayAgain }: ScoreScreenProps) => { return (
-
- {percentage === 100 && } -
-
- -

- Results -

-

+
+

Your Score

+

{score}/{total}

-

{getMessage()}

+

{getMessage()}

-
+
-

- {percentage}% correct -

+

{percentage}% correct

{results.map((result, index) => (
{index + 1}. @@ -60,9 +51,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 deleted file mode 100644 index e8085c2..0000000 --- a/apps/web/src/components/landing/FeatureCards.tsx +++ /dev/null @@ -1,68 +0,0 @@ -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: "Real-time multiplayer", - description: "Create a room, share the code, and race to the best score.", - }, -]; - -const FeatureCards = () => { - return ( -
-
-
- 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}

-
-

- {description} -

-
- - - Instant feedback - - - - Type-safe API - -
-
- ))} -
-
- ); -}; - -export default FeatureCards; diff --git a/apps/web/src/components/landing/Hero.tsx b/apps/web/src/components/landing/Hero.tsx deleted file mode 100644 index 6a6de87..0000000 --- a/apps/web/src/components/landing/Hero.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { Link } from "@tanstack/react-router"; -import { useSession } from "../../lib/auth-client"; - -const Hero = () => { - const { data: session } = useSession(); - - return ( -
-
-
-
-
- -
-
-
- - Duolingo-style drills · real-time multiplayer - -
- -

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

- -

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

- -
- {["🇬🇧", "🇮🇹", "🇩🇪", "🇫🇷", "🇪🇸"].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 -
-
-
-
-
-
-
-
- ); -}; - -export default Hero; diff --git a/apps/web/src/components/landing/HowItWorks.tsx b/apps/web/src/components/landing/HowItWorks.tsx deleted file mode 100644 index 8255493..0000000 --- a/apps/web/src/components/landing/HowItWorks.tsx +++ /dev/null @@ -1,77 +0,0 @@ -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 ( -
-
-
-
-
-
- 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. - ))} -
-
-
- ); -}; - -export default HowItWorks; diff --git a/apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx b/apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx index f530db8..5e95588 100644 --- a/apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx +++ b/apps/web/src/components/multiplayer/MultiplayerScoreScreen.tsx @@ -1,6 +1,5 @@ import { useNavigate } from "@tanstack/react-router"; import type { LobbyPlayer } from "@lila/shared"; -import { ConfettiBurst } from "../ui/ConfettiBurst"; type MultiplayerScoreScreenProps = { players: LobbyPlayer[]; @@ -27,27 +26,19 @@ 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 */}
@@ -57,35 +48,35 @@ export const MultiplayerScoreScreen = ({ return (
- + {index + 1}. {player.user.name} {isCurrentUser && ( - + (you) )} {isPlayerWinner && ( - + 👑 )}
- + {player.score} pts
@@ -93,12 +84,12 @@ export const MultiplayerScoreScreen = ({ })}
-
+
{/* Actions */}
- ) : ( - - Sign in - - )} -
- ); -}; - -export default NavAuth; diff --git a/apps/web/src/components/navbar/NavBar.tsx b/apps/web/src/components/navbar/NavBar.tsx deleted file mode 100644 index b5bb494..0000000 --- a/apps/web/src/components/navbar/NavBar.tsx +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index c0dae7b..0000000 --- a/apps/web/src/components/navbar/NavLink.tsx +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 5040c83..0000000 --- a/apps/web/src/components/navbar/NavLinks.tsx +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index f28bfdd..0000000 --- a/apps/web/src/components/navbar/NavLogin.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Link } from "@tanstack/react-router"; - -const NavLogin = () => { - return ( - - Login - - ); -}; - -export default NavLogin; diff --git a/apps/web/src/components/navbar/NavLogout.tsx b/apps/web/src/components/navbar/NavLogout.tsx deleted file mode 100644 index ec297cf..0000000 --- a/apps/web/src/components/navbar/NavLogout.tsx +++ /dev/null @@ -1,26 +0,0 @@ -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/components/ui/ConfettiBurst.tsx b/apps/web/src/components/ui/ConfettiBurst.tsx deleted file mode 100644 index 66285d4..0000000 --- a/apps/web/src/components/ui/ConfettiBurst.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { useEffect, useMemo, useState, useId } from "react"; - -type ConfettiBurstProps = { - className?: string; - colors?: string[]; - count?: number; -}; - -type Piece = { - id: number; - style: React.CSSProperties & ConfettiVars; -}; - -type ConfettiVars = { - ["--x0"]: string; - ["--y0"]: string; - ["--x1"]: string; - ["--y1"]: string; -}; - -const hashStringToUint32 = (value: string) => { - // FNV-1a 32-bit - let hash = 2166136261; - for (let i = 0; i < value.length; i++) { - hash ^= value.charCodeAt(i); - hash = Math.imul(hash, 16777619); - } - return hash >>> 0; -}; - -const mulberry32 = (seed: number) => { - return () => { - let t = (seed += 0x6d2b79f5); - t = Math.imul(t ^ (t >>> 15), t | 1); - t ^= t + Math.imul(t ^ (t >>> 7), t | 61); - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -}; - -export const ConfettiBurst = ({ - className, - colors = [ - "var(--color-primary)", - "var(--color-accent)", - "var(--color-primary-light)", - "var(--color-accent-light)", - ], - count = 18, -}: ConfettiBurstProps) => { - const [visible, setVisible] = useState(true); - const instanceId = useId(); - - useEffect(() => { - const t = window.setTimeout(() => setVisible(false), 1100); - return () => window.clearTimeout(t); - }, []); - - const pieces = useMemo(() => { - const seed = hashStringToUint32(`${instanceId}:${count}:${colors.join(",")}`); - const rand = mulberry32(seed); - const rnd = (min: number, max: number) => min + rand() * (max - min); - - return Array.from({ length: count }).map((_, i) => { - const x0 = rnd(-6, 6); - const y0 = rnd(-6, 6); - const x1 = rnd(-160, 160); - const y1 = rnd(60, 220); - const delay = rnd(0, 120); - const rotate = rnd(0, 360); - const color = colors[i % colors.length]; - - return { - id: i, - style: { - left: "50%", - top: "0%", - backgroundColor: color, - transform: `translate(${x0}px, ${y0}px) rotate(${rotate}deg)`, - animationDelay: `${delay}ms`, - // consumed by keyframes - ["--x0"]: `${x0}px`, - ["--y0"]: `${y0}px`, - ["--x1"]: `${x1}px`, - ["--y1"]: `${y1}px`, - }, - }; - }); - }, [colors, count, instanceId]); - - if (!visible) return null; - - return ( -
- {pieces.map((p) => ( - - ))} -
- ); -}; - diff --git a/apps/web/src/index.css b/apps/web/src/index.css index c4b2853..f1d8c73 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,90 +1 @@ @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); - } -} - -@keyframes lila-pop { - 0% { - transform: scale(1); - } - 40% { - transform: scale(1.03); - } - 100% { - transform: scale(1); - } -} - -@keyframes lila-shake { - 0%, - 100% { - transform: translateX(0); - } - 20% { - transform: translateX(-3px); - } - 40% { - transform: translateX(3px); - } - 60% { - transform: translateX(-2px); - } - 80% { - transform: translateX(2px); - } -} - -@keyframes lila-confetti { - 0% { - transform: translate(var(--x0), var(--y0)) rotate(0deg); - opacity: 0; - } - 10% { - opacity: 1; - } - 100% { - transform: translate(var(--x1), var(--y1)) rotate(540deg); - opacity: 0; - } -} - -.lila-pop { - animation: lila-pop 220ms ease-out; -} - -.lila-shake { - animation: lila-shake 260ms ease-in-out; -} - -.lila-confetti-piece { - position: absolute; - width: 8px; - height: 12px; - border-radius: 3px; - animation: lila-confetti 900ms ease-out forwards; - will-change: transform, opacity; -} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 1dc4378..0add685 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,14 +1,56 @@ -import { createRootRoute, Outlet } from "@tanstack/react-router"; +import { + createRootRoute, + Link, + Outlet, + useNavigate, +} from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; -import Navbar from "../components/navbar/NavBar"; +import { useSession, signOut } from "../lib/auth-client"; const RootLayout = () => { + const { data: session } = useSession(); + const navigate = useNavigate(); + return ( <> - -
- -
+
+ + Home + + + Play + + + Multiplayer + +
+ {session ? ( + + ) : ( + + Sign in + + )} +
+
+
+ ); diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 2e9fd80..a17910a 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,16 +1,11 @@ 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!

); } diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx index 8451d41..ff7e8d9 100644 --- a/apps/web/src/routes/login.tsx +++ b/apps/web/src/routes/login.tsx @@ -16,7 +16,7 @@ const LoginPage = () => {

sign in to lila

-
+
{/* 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} @@ -137,7 +135,7 @@ function LobbyPage() { {/* Start button — host only */} {isHost && (