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. 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 new file mode 100644 index 0000000..e8085c2 --- /dev/null +++ b/apps/web/src/components/landing/FeatureCards.tsx @@ -0,0 +1,68 @@ +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 new file mode 100644 index 0000000..6a6de87 --- /dev/null +++ b/apps/web/src/components/landing/Hero.tsx @@ -0,0 +1,139 @@ +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 new file mode 100644 index 0000000..8255493 --- /dev/null +++ b/apps/web/src/components/landing/HowItWorks.tsx @@ -0,0 +1,77 @@ +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 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 */}
+ ) : ( + + 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..f28bfdd --- /dev/null +++ b/apps/web/src/components/navbar/NavLogin.tsx @@ -0,0 +1,17 @@ +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 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/components/ui/ConfettiBurst.tsx b/apps/web/src/components/ui/ConfettiBurst.tsx new file mode 100644 index 0000000..66285d4 --- /dev/null +++ b/apps/web/src/components/ui/ConfettiBurst.tsx @@ -0,0 +1,103 @@ +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 f1d8c73..c4b2853 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1 +1,90 @@ @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 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 - - )} -
-
-
- + +
+ +
); 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!

+
+ + +
); } diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx index ff7e8d9..8451d41 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} @@ -135,7 +137,7 @@ function LobbyPage() { {/* Start button — host only */} {isHost && (