Compare commits
No commits in common. "main" and "feat/multiplayer-mode" have entirely different histories.
main
...
feat/multi
30 changed files with 464 additions and 1083 deletions
|
|
@ -8,9 +8,6 @@ jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
steps:
|
steps:
|
||||||
- name: Install tools
|
|
||||||
run: apt-get update && apt-get install -y docker.io openssh-client
|
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
|
|
||||||
169
README.md
169
README.md
|
|
@ -1,170 +1 @@
|
||||||
# lila
|
# 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.
|
|
||||||
|
|
|
||||||
|
|
@ -35,18 +35,16 @@ const SettingGroup = ({
|
||||||
onSelect,
|
onSelect,
|
||||||
}: SettingGroupProps) => (
|
}: SettingGroupProps) => (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<p className="text-xs font-bold tracking-widest uppercase text-(--color-primary) mb-2">
|
<p className="text-sm font-medium text-purple-400 mb-2">{label}</p>
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option}
|
key={option}
|
||||||
onClick={() => onSelect(option)}
|
onClick={() => onSelect(option)}
|
||||||
className={`py-2 px-5 rounded-xl font-semibold text-sm border transition-all duration-200 cursor-pointer ${
|
className={`py-2 px-5 rounded-xl font-semibold text-sm border-b-4 transition-all duration-200 cursor-pointer ${
|
||||||
selected === option
|
selected === option
|
||||||
? "bg-(--color-primary) text-white border-(--color-primary-dark) shadow-sm"
|
? "bg-purple-600 text-white border-purple-800"
|
||||||
: "bg-white text-(--color-primary-dark) border-(--color-primary-light) hover:bg-(--color-surface) hover:-translate-y-0.5 active:translate-y-0"
|
: "bg-white text-purple-900 border-purple-200 hover:bg-purple-50 hover:border-purple-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{LABELS[option] ?? option}
|
{LABELS[option] ?? option}
|
||||||
|
|
@ -93,18 +91,12 @@ export const GameSetup = ({ onStart }: GameSetupProps) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-6 w-full max-w-md mx-auto">
|
<div className="flex flex-col items-center gap-6 w-full max-w-md mx-auto">
|
||||||
<div className="relative overflow-hidden w-full rounded-3xl border border-(--color-primary-light) bg-white dark:bg-black/10 shadow-sm p-8 text-center">
|
<div className="bg-white rounded-3xl shadow-lg p-8 w-full text-center">
|
||||||
<div className="absolute -top-16 -left-20 h-40 w-40 rounded-full bg-(--color-accent) opacity-[0.10] blur-3xl" />
|
<h1 className="text-3xl font-bold text-purple-900 mb-1">lila</h1>
|
||||||
<div className="absolute -bottom-20 -right-20 h-44 w-44 rounded-full bg-(--color-primary) opacity-[0.12] blur-3xl" />
|
<p className="text-sm text-gray-400">Set up your quiz</p>
|
||||||
<h1 className="relative text-3xl font-black tracking-tight text-(--color-text) mb-1">
|
|
||||||
lila
|
|
||||||
</h1>
|
|
||||||
<p className="relative text-sm text-(--color-text-muted)">
|
|
||||||
Set up your quiz
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full rounded-3xl border border-(--color-primary-light) bg-white dark:bg-black/10 shadow-sm p-6 flex flex-col gap-5">
|
<div className="bg-white rounded-3xl shadow-lg p-6 w-full flex flex-col gap-5">
|
||||||
<SettingGroup
|
<SettingGroup
|
||||||
label="I speak"
|
label="I speak"
|
||||||
options={SUPPORTED_LANGUAGE_CODES}
|
options={SUPPORTED_LANGUAGE_CODES}
|
||||||
|
|
@ -139,9 +131,9 @@ export const GameSetup = ({ onStart }: GameSetupProps) => {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleStart}
|
onClick={handleStart}
|
||||||
className="w-full py-4 rounded-2xl text-xl font-black bg-linear-to-r from-pink-400 to-purple-500 text-white shadow-sm hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 transition-all duration-200 cursor-pointer"
|
className="w-full py-4 rounded-2xl text-xl font-bold bg-linear-to-r from-pink-400 to-purple-500 text-white border-b-4 border-purple-700 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2 transition-all duration-200 cursor-pointer"
|
||||||
>
|
>
|
||||||
Start
|
Start Quiz
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,39 +6,26 @@ type OptionButtonProps = {
|
||||||
|
|
||||||
export const OptionButton = ({ text, state, onSelect }: OptionButtonProps) => {
|
export const OptionButton = ({ text, state, onSelect }: OptionButtonProps) => {
|
||||||
const base =
|
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 = {
|
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:
|
selected:
|
||||||
"bg-(--color-surface) text-(--color-primary-dark) border-(--color-primary) ring-2 ring-(--color-primary)",
|
"bg-purple-100 text-purple-900 border-purple-400 ring-2 ring-purple-400",
|
||||||
disabled:
|
disabled: "bg-gray-100 text-gray-400 border-gray-200 cursor-default",
|
||||||
"bg-(--color-surface) text-(--color-primary-light) border-(--color-primary-light) cursor-default",
|
correct: "bg-emerald-400 text-white border-emerald-600 scale-[1.02]",
|
||||||
correct:
|
wrong: "bg-pink-400 text-white border-pink-600",
|
||||||
"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 (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`${base} ${styles[state]} ${motion}`}
|
className={`${base} ${styles[state]}`}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
disabled={
|
disabled={
|
||||||
state === "disabled" || state === "correct" || state === "wrong"
|
state === "disabled" || state === "correct" || state === "wrong"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="absolute inset-0 -z-10 opacity-0 group-hover:opacity-100 transition-opacity">
|
{text}
|
||||||
<span className="absolute -top-10 -right-12 h-24 w-24 rounded-full bg-(--color-primary) opacity-[0.10] blur-2xl" />
|
|
||||||
<span className="absolute -bottom-10 -left-12 h-24 w-24 rounded-full bg-(--color-accent) opacity-[0.10] blur-2xl" />
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center justify-between gap-3">
|
|
||||||
<span className="truncate">{text}</span>
|
|
||||||
{state === "correct" && <span aria-hidden>✓</span>}
|
|
||||||
{state === "wrong" && <span aria-hidden>✕</span>}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -48,31 +48,22 @@ export const QuestionCard = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-6 w-full max-w-md mx-auto">
|
<div className="flex flex-col items-center gap-6 w-full max-w-md mx-auto">
|
||||||
<div className="w-full flex items-center justify-between">
|
<div className="flex items-center gap-2 text-sm font-medium text-purple-400">
|
||||||
<div className="inline-flex items-center gap-2 rounded-full bg-(--color-surface) border border-(--color-primary-light) px-4 py-1 text-xs font-bold tracking-widest uppercase text-(--color-primary)">
|
<span>
|
||||||
Round {questionNumber}/{totalQuestions}
|
{questionNumber} / {totalQuestions}
|
||||||
</div>
|
</span>
|
||||||
<div className="text-xs font-semibold text-(--color-text-muted)">
|
|
||||||
{currentResult ? "Checked" : selectedOptionId !== null ? "Ready" : "Pick one"}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative w-full overflow-hidden rounded-3xl border border-(--color-primary-light) bg-white/40 dark:bg-black/10 backdrop-blur shadow-sm p-8 text-center">
|
<div className="bg-white rounded-3xl shadow-lg p-8 w-full text-center">
|
||||||
<div className="absolute -top-16 -left-20 h-40 w-40 rounded-full bg-(--color-accent) opacity-[0.10] blur-3xl" />
|
<h2 className="text-3xl font-bold text-purple-900 mb-2">
|
||||||
<div className="absolute -bottom-20 -right-20 h-44 w-44 rounded-full bg-(--color-primary) opacity-[0.12] blur-3xl" />
|
|
||||||
|
|
||||||
<h2 className="relative text-3xl font-black tracking-tight text-(--color-text) mb-2">
|
|
||||||
{question.prompt}
|
{question.prompt}
|
||||||
</h2>
|
</h2>
|
||||||
{question.gloss && (
|
{question.gloss && (
|
||||||
<p className="relative text-sm text-(--color-text-muted) italic">
|
<p className="text-sm text-gray-400 italic">{question.gloss}</p>
|
||||||
{question.gloss}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full rounded-3xl border border-(--color-primary-light) bg-white/55 dark:bg-black/10 backdrop-blur shadow-sm p-4">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{question.options.map((option) => (
|
{question.options.map((option) => (
|
||||||
<OptionButton
|
<OptionButton
|
||||||
key={option.optionId}
|
key={option.optionId}
|
||||||
|
|
@ -81,22 +72,21 @@ export const QuestionCard = ({
|
||||||
onSelect={() => handleSelect(option.optionId)}
|
onSelect={() => handleSelect(option.optionId)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!currentResult && selectedOptionId !== null && (
|
{!currentResult && selectedOptionId !== null && (
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
className="w-full py-3 rounded-2xl text-lg font-bold bg-linear-to-r from-pink-400 to-purple-500 text-white shadow-sm hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 transition-all duration-200 cursor-pointer"
|
className="w-full py-3 rounded-2xl text-lg font-bold bg-linear-to-r from-pink-400 to-purple-500 text-white border-b-4 border-purple-700 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2 transition-all duration-200 cursor-pointer"
|
||||||
>
|
>
|
||||||
Lock it in
|
Submit
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentResult && (
|
{currentResult && (
|
||||||
<button
|
<button
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
className="w-full py-3 rounded-2xl text-lg font-bold bg-(--color-primary) text-white shadow-sm hover:shadow-md hover:bg-(--color-primary-dark) hover:-translate-y-0.5 active:translate-y-0 transition-all duration-200 cursor-pointer"
|
className="w-full py-3 rounded-2xl text-lg font-bold bg-purple-600 text-white border-b-4 border-purple-800 hover:bg-purple-500 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2 transition-all duration-200 cursor-pointer"
|
||||||
>
|
>
|
||||||
{questionNumber === totalQuestions ? "See Results" : "Next"}
|
{questionNumber === totalQuestions ? "See Results" : "Next"}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import type { AnswerResult } from "@lila/shared";
|
import type { AnswerResult } from "@lila/shared";
|
||||||
import { ConfettiBurst } from "../ui/ConfettiBurst";
|
|
||||||
|
|
||||||
type ScoreScreenProps = { results: AnswerResult[]; onPlayAgain: () => void };
|
type ScoreScreenProps = { results: AnswerResult[]; onPlayAgain: () => void };
|
||||||
|
|
||||||
|
|
@ -18,38 +17,30 @@ export const ScoreScreen = ({ results, onPlayAgain }: ScoreScreenProps) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-8 w-full max-w-md mx-auto">
|
<div className="flex flex-col items-center gap-8 w-full max-w-md mx-auto">
|
||||||
<div className="relative overflow-hidden w-full rounded-3xl border border-(--color-primary-light) bg-white/40 dark:bg-black/10 backdrop-blur shadow-sm p-10 text-center">
|
<div className="bg-white rounded-3xl shadow-lg p-10 w-full text-center">
|
||||||
{percentage === 100 && <ConfettiBurst />}
|
<p className="text-lg font-medium text-purple-400 mb-2">Your Score</p>
|
||||||
<div className="absolute -top-20 -left-24 h-56 w-56 rounded-full bg-(--color-accent) opacity-[0.10] blur-3xl" />
|
<h2 className="text-6xl font-bold text-purple-900 mb-1">
|
||||||
<div className="absolute -bottom-24 -right-20 h-64 w-64 rounded-full bg-(--color-primary) opacity-[0.12] blur-3xl" />
|
|
||||||
|
|
||||||
<p className="relative inline-flex items-center gap-2 rounded-full bg-(--color-surface) border border-(--color-primary-light) px-4 py-1 text-xs font-bold tracking-widest uppercase text-(--color-primary) mb-3">
|
|
||||||
Results
|
|
||||||
</p>
|
|
||||||
<h2 className="relative text-6xl font-black tracking-tight text-(--color-text) mb-1">
|
|
||||||
{score}/{total}
|
{score}/{total}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="relative text-2xl mb-6">{getMessage()}</p>
|
<p className="text-2xl mb-6">{getMessage()}</p>
|
||||||
|
|
||||||
<div className="relative w-full bg-(--color-surface) border border-(--color-primary-light) rounded-full h-4 mb-2 overflow-hidden">
|
<div className="w-full bg-purple-100 rounded-full h-4 mb-2">
|
||||||
<div
|
<div
|
||||||
className="bg-linear-to-r from-pink-400 to-purple-500 h-4 rounded-full transition-all duration-700"
|
className="bg-linear-to-r from-pink-400 to-purple-500 h-4 rounded-full transition-all duration-700"
|
||||||
style={{ width: `${percentage}%` }}
|
style={{ width: `${percentage}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="relative text-sm text-(--color-text-muted)">
|
<p className="text-sm text-gray-400">{percentage}% correct</p>
|
||||||
{percentage}% correct
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 w-full">
|
<div className="flex flex-col gap-2 w-full">
|
||||||
{results.map((result, index) => (
|
{results.map((result, index) => (
|
||||||
<div
|
<div
|
||||||
key={result.questionId}
|
key={result.questionId}
|
||||||
className={`flex items-center gap-3 py-2 px-4 rounded-xl text-sm border ${
|
className={`flex items-center gap-3 py-2 px-4 rounded-xl text-sm ${
|
||||||
result.isCorrect
|
result.isCorrect
|
||||||
? "bg-emerald-50/60 text-emerald-700 border-emerald-200"
|
? "bg-emerald-50 text-emerald-700"
|
||||||
: "bg-pink-50/60 text-pink-700 border-pink-200"
|
: "bg-pink-50 text-pink-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="font-bold">{index + 1}.</span>
|
<span className="font-bold">{index + 1}.</span>
|
||||||
|
|
@ -60,9 +51,9 @@ export const ScoreScreen = ({ results, onPlayAgain }: ScoreScreenProps) => {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onPlayAgain}
|
onClick={onPlayAgain}
|
||||||
className="w-full py-3 px-10 rounded-2xl text-lg font-black bg-(--color-primary) text-white shadow-sm hover:shadow-md hover:bg-(--color-primary-dark) hover:-translate-y-0.5 active:translate-y-0 transition-all duration-200 cursor-pointer"
|
className="py-3 px-10 rounded-2xl text-lg font-bold bg-purple-600 text-white border-b-4 border-purple-800 hover:bg-purple-500 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2 transition-all duration-200 cursor-pointer"
|
||||||
>
|
>
|
||||||
Play again
|
Play Again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
|
||||||
<section className="py-14">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="inline-flex items-center gap-2 rounded-full bg-(--color-surface) border border-(--color-primary-light) px-4 py-1 text-xs font-bold tracking-widest uppercase text-(--color-primary)">
|
|
||||||
Tiny rounds · big dopamine
|
|
||||||
</div>
|
|
||||||
<h2 className="text-3xl font-black tracking-tight text-(--color-text)">
|
|
||||||
Why lila
|
|
||||||
</h2>
|
|
||||||
<p className="mt-3 text-(--color-text-muted) max-w-2xl mx-auto">
|
|
||||||
Built to be fast to start, satisfying to finish, and fun to repeat.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-10 grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
{features.map(({ emoji, title, description }) => (
|
|
||||||
<div
|
|
||||||
key={title}
|
|
||||||
className="group relative overflow-hidden rounded-2xl border border-(--color-primary-light) bg-(--color-bg) p-6 shadow-sm hover:shadow-lg transition-shadow"
|
|
||||||
>
|
|
||||||
<div className="absolute -top-24 -right-24 h-48 w-48 rounded-full bg-(--color-primary) opacity-[0.08] blur-2xl transition-transform duration-300 group-hover:translate-x-2 group-hover:-translate-y-2" />
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="relative h-12 w-12 rounded-2xl bg-(--color-surface) border border-(--color-primary-light) grid place-items-center text-2xl">
|
|
||||||
<span aria-hidden>{emoji}</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-bold text-(--color-text)">{title}</h3>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-sm text-(--color-text-muted) leading-relaxed">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
<div className="mt-5 flex flex-wrap gap-2">
|
|
||||||
<span className="inline-flex items-center gap-2 rounded-full bg-(--color-surface) border border-(--color-primary-light) px-3 py-1 text-xs font-bold text-(--color-primary-dark)">
|
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-(--color-accent)" />
|
|
||||||
Instant feedback
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center gap-2 rounded-full bg-(--color-surface) border border-(--color-primary-light) px-3 py-1 text-xs font-bold text-(--color-primary-dark)">
|
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-(--color-primary)" />
|
|
||||||
Type-safe API
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FeatureCards;
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
import { useSession } from "../../lib/auth-client";
|
|
||||||
|
|
||||||
const Hero = () => {
|
|
||||||
const { data: session } = useSession();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="relative pt-10 md:pt-16 pb-10 md:pb-14">
|
|
||||||
<div className="absolute inset-0 -z-10">
|
|
||||||
<div className="absolute -top-24 left-1/2 h-72 w-[46rem] -translate-x-1/2 rounded-full bg-(--color-primary) opacity-[0.10] blur-3xl" />
|
|
||||||
<div className="absolute -top-10 left-1/2 h-72 w-[46rem] -translate-x-1/2 rounded-full bg-(--color-accent) opacity-[0.10] blur-3xl" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid items-center gap-10 md:grid-cols-2">
|
|
||||||
<div className="text-center md:text-left">
|
|
||||||
<div className="-rotate-1 mb-3">
|
|
||||||
<span className="inline-flex items-center gap-2 rounded-full bg-(--color-surface) px-4 py-1 text-xs font-semibold tracking-widest uppercase text-(--color-accent) border border-(--color-primary-light)">
|
|
||||||
Duolingo-style drills · real-time multiplayer
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="text-5xl md:text-6xl font-black tracking-tight text-(--color-text) leading-[1.05]">
|
|
||||||
Learn vocabulary fast,{" "}
|
|
||||||
<span className="inline-block rotate-1 px-3 py-1 bg-(--color-primary) text-white rounded-xl">
|
|
||||||
together
|
|
||||||
</span>
|
|
||||||
.
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="mt-5 text-lg md:text-xl font-medium text-(--color-text-muted) max-w-xl mx-auto md:mx-0">
|
|
||||||
A word appears. You pick the translation. You score points.
|
|
||||||
Then you queue up a room and{" "}
|
|
||||||
<span className="text-(--color-accent) font-bold">beat friends</span>{" "}
|
|
||||||
in real time.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-4 flex gap-2 flex-wrap justify-center md:justify-start">
|
|
||||||
{["🇬🇧", "🇮🇹", "🇩🇪", "🇫🇷", "🇪🇸"].map((flag) => (
|
|
||||||
<span key={flag} className="text-2xl" aria-hidden>
|
|
||||||
{flag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
<span className="sr-only">
|
|
||||||
Supported languages: English, Italian, German, French, Spanish
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 flex gap-3 flex-wrap justify-center md:justify-start">
|
|
||||||
{session ? (
|
|
||||||
<>
|
|
||||||
<Link
|
|
||||||
to="/play"
|
|
||||||
className="px-7 py-3 rounded-full text-white font-bold text-sm bg-(--color-primary) hover:bg-(--color-primary-dark)"
|
|
||||||
>
|
|
||||||
Play solo
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/multiplayer"
|
|
||||||
className="px-7 py-3 rounded-full font-bold text-sm text-(--color-primary) border-2 border-(--color-primary) hover:bg-(--color-surface)"
|
|
||||||
>
|
|
||||||
Play with friends
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Link
|
|
||||||
to="/login"
|
|
||||||
className="px-7 py-3 rounded-full text-white font-bold text-sm bg-(--color-primary) hover:bg-(--color-primary-dark)"
|
|
||||||
>
|
|
||||||
Get started
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/login"
|
|
||||||
className="px-7 py-3 rounded-full font-bold text-sm text-(--color-primary) border-2 border-(--color-primary) hover:bg-(--color-surface)"
|
|
||||||
>
|
|
||||||
Log in
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<div className="rounded-3xl border border-(--color-primary-light) bg-white/40 dark:bg-black/20 backdrop-blur p-3 shadow-sm">
|
|
||||||
<div className="rounded-2xl bg-(--color-bg) border border-(--color-primary-light) overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 bg-(--color-surface) border-b border-(--color-primary-light)">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-2.5 w-2.5 rounded-full bg-(--color-accent)" />
|
|
||||||
<div className="h-2.5 w-2.5 rounded-full bg-(--color-primary-light)" />
|
|
||||||
<div className="h-2.5 w-2.5 rounded-full bg-(--color-text-muted) opacity-40" />
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-semibold text-(--color-text-muted)">
|
|
||||||
Live preview
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-5 md:p-6">
|
|
||||||
<p className="text-xs font-semibold tracking-widest uppercase text-(--color-text-muted)">
|
|
||||||
Translate
|
|
||||||
</p>
|
|
||||||
<div className="mt-2 rounded-2xl bg-(--color-surface) border border-(--color-primary-light) px-4 py-5">
|
|
||||||
<div className="text-3xl font-black text-(--color-text)">
|
|
||||||
finestra
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-sm text-(--color-text-muted)">
|
|
||||||
(noun) · A2
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
|
||||||
{["window", "forest", "river", "kitchen"].map((opt) => (
|
|
||||||
<div
|
|
||||||
key={opt}
|
|
||||||
className="rounded-xl border border-(--color-primary-light) bg-white/30 dark:bg-black/10 px-4 py-3 text-sm font-semibold text-(--color-text) hover:bg-(--color-surface)"
|
|
||||||
>
|
|
||||||
{opt}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 flex items-center justify-between">
|
|
||||||
<div className="text-xs text-(--color-text-muted)">
|
|
||||||
Round 2/10
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex items-center gap-2 rounded-full bg-(--color-surface) border border-(--color-primary-light) px-3 py-1 text-xs font-semibold text-(--color-text-muted)">
|
|
||||||
<span className="h-2 w-2 rounded-full bg-(--color-accent)" />
|
|
||||||
Multiplayer room
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Hero;
|
|
||||||
|
|
@ -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 (
|
|
||||||
<section className="py-14">
|
|
||||||
<div className="relative -mx-6 px-6 py-12 rounded-3xl bg-(--color-surface) border border-(--color-primary-light) overflow-hidden">
|
|
||||||
<div className="absolute -top-20 -left-24 h-56 w-56 rounded-full bg-(--color-accent) opacity-[0.12] blur-3xl" />
|
|
||||||
<div className="absolute -bottom-24 -right-20 h-64 w-64 rounded-full bg-(--color-primary) opacity-[0.14] blur-3xl" />
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="inline-flex items-center gap-2 rounded-full bg-(--color-bg) border border-(--color-primary-light) px-4 py-1 text-xs font-bold tracking-widest uppercase text-(--color-accent)">
|
|
||||||
Quick · satisfying · replayable
|
|
||||||
</div>
|
|
||||||
<h2 className="mt-3 text-3xl font-black tracking-tight text-(--color-text)">
|
|
||||||
How it works
|
|
||||||
</h2>
|
|
||||||
<p className="mt-3 text-(--color-text-muted) max-w-2xl mx-auto">
|
|
||||||
Short rounds, instant feedback, and just enough pressure to make the
|
|
||||||
words stick.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ol className="relative mt-10 grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
{steps.map(({ number, title, description }) => (
|
|
||||||
<li
|
|
||||||
key={number}
|
|
||||||
className="group relative overflow-hidden rounded-2xl bg-(--color-bg) border border-(--color-primary-light) p-6 shadow-sm hover:shadow-lg transition-shadow"
|
|
||||||
>
|
|
||||||
<div className="absolute -top-24 -right-24 h-48 w-48 rounded-full bg-(--color-primary) opacity-[0.10] blur-2xl transition-transform duration-300 group-hover:translate-x-2 group-hover:-translate-y-2" />
|
|
||||||
<div className="absolute -bottom-24 -left-24 h-48 w-48 rounded-full bg-(--color-accent) opacity-[0.08] blur-2xl transition-transform duration-300 group-hover:-translate-x-2 group-hover:translate-y-2" />
|
|
||||||
<div className="relative flex items-start gap-4">
|
|
||||||
<div className="shrink-0">
|
|
||||||
<div className="h-12 w-12 rounded-2xl bg-(--color-surface) border border-(--color-primary-light) grid place-items-center">
|
|
||||||
<span className="text-sm font-black tracking-widest text-(--color-primary)">
|
|
||||||
{number}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-bold text-(--color-text)">
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-2 text-sm text-(--color-text-muted) leading-relaxed">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-(--color-surface) border border-(--color-primary-light) px-3 py-1 text-xs font-bold text-(--color-primary-dark)">
|
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-(--color-accent)" />
|
|
||||||
Under 30 seconds
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HowItWorks;
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import type { LobbyPlayer } from "@lila/shared";
|
import type { LobbyPlayer } from "@lila/shared";
|
||||||
import { ConfettiBurst } from "../ui/ConfettiBurst";
|
|
||||||
|
|
||||||
type MultiplayerScoreScreenProps = {
|
type MultiplayerScoreScreenProps = {
|
||||||
players: LobbyPlayer[];
|
players: LobbyPlayer[];
|
||||||
|
|
@ -27,27 +26,19 @@ export const MultiplayerScoreScreen = ({
|
||||||
.join(" and ");
|
.join(" and ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen relative flex items-center justify-center p-6">
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
||||||
<div className="absolute inset-0 -z-10 bg-linear-to-b from-purple-100 to-pink-50" />
|
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
|
||||||
<div className="absolute -top-24 left-1/2 -translate-x-1/2 h-72 w-[46rem] rounded-full bg-(--color-primary) opacity-[0.12] blur-3xl -z-10" />
|
|
||||||
<div className="absolute -top-8 left-1/2 -translate-x-1/2 h-72 w-[46rem] rounded-full bg-(--color-accent) opacity-[0.10] blur-3xl -z-10" />
|
|
||||||
|
|
||||||
<div className="w-full max-w-md rounded-3xl border border-(--color-primary-light) bg-white/50 dark:bg-black/10 backdrop-blur shadow-sm p-8 flex flex-col gap-6">
|
|
||||||
{isWinner && !isTie && <ConfettiBurst />}
|
|
||||||
{/* Result header */}
|
{/* Result header */}
|
||||||
<div className="text-center flex flex-col gap-1">
|
<div className="text-center flex flex-col gap-1">
|
||||||
<div className="inline-flex mx-auto items-center gap-2 rounded-full bg-(--color-surface) border border-(--color-primary-light) px-4 py-1 text-xs font-bold tracking-widest uppercase text-(--color-primary)">
|
<h1 className="text-2xl font-bold text-purple-800">
|
||||||
Multiplayer
|
|
||||||
</div>
|
|
||||||
<h1 className="mt-2 text-2xl font-black tracking-tight text-(--color-text)">
|
|
||||||
{isTie ? "It's a tie!" : isWinner ? "You win! 🎉" : "Game over"}
|
{isTie ? "It's a tie!" : isWinner ? "You win! 🎉" : "Game over"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-(--color-text-muted)">
|
<p className="text-sm text-gray-500">
|
||||||
{isTie ? `${winnerNames} tied` : `${winnerNames} wins!`}
|
{isTie ? `${winnerNames} tied` : `${winnerNames} wins!`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-(--color-primary-light) opacity-60" />
|
<div className="border-t border-gray-200" />
|
||||||
|
|
||||||
{/* Score list */}
|
{/* Score list */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|
@ -57,35 +48,35 @@ export const MultiplayerScoreScreen = ({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={player.userId}
|
key={player.userId}
|
||||||
className={`flex items-center justify-between rounded-2xl px-4 py-3 border ${
|
className={`flex items-center justify-between rounded-lg px-4 py-3 ${
|
||||||
isCurrentUser
|
isCurrentUser
|
||||||
? "bg-(--color-surface) border-(--color-primary-light)"
|
? "bg-purple-50 border border-purple-200"
|
||||||
: "bg-white/30 dark:bg-black/10 border-(--color-primary-light)"
|
: "bg-gray-50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm font-bold text-(--color-text-muted) w-4">
|
<span className="text-sm font-medium text-gray-400 w-4">
|
||||||
{index + 1}.
|
{index + 1}.
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`text-sm font-semibold ${
|
className={`text-sm font-medium ${
|
||||||
isCurrentUser ? "text-(--color-text)" : "text-(--color-text)"
|
isCurrentUser ? "text-purple-800" : "text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{player.user.name}
|
{player.user.name}
|
||||||
{isCurrentUser && (
|
{isCurrentUser && (
|
||||||
<span className="text-xs text-(--color-primary) ml-1">
|
<span className="text-xs text-purple-400 ml-1">
|
||||||
(you)
|
(you)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{isPlayerWinner && (
|
{isPlayerWinner && (
|
||||||
<span className="text-xs font-medium" aria-label="Winner">
|
<span className="text-xs text-yellow-500 font-medium">
|
||||||
👑
|
👑
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-black text-(--color-text)">
|
<span className="text-sm font-bold text-gray-700">
|
||||||
{player.score} pts
|
{player.score} pts
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -93,12 +84,12 @@ export const MultiplayerScoreScreen = ({
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-(--color-primary-light) opacity-60" />
|
<div className="border-t border-gray-200" />
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<button
|
<button
|
||||||
className="rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-black hover:bg-(--color-primary-dark) shadow-sm hover:shadow-md transition-all"
|
className="rounded bg-purple-600 px-4 py-2 text-white hover:bg-purple-500"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigate({
|
void navigate({
|
||||||
to: "/multiplayer/lobby/$code",
|
to: "/multiplayer/lobby/$code",
|
||||||
|
|
@ -109,7 +100,7 @@ export const MultiplayerScoreScreen = ({
|
||||||
Play Again
|
Play Again
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="rounded-2xl bg-white/30 dark:bg-black/10 border border-(--color-primary-light) px-4 py-3 text-(--color-text) font-bold hover:bg-(--color-surface) transition-colors"
|
className="rounded bg-gray-100 px-4 py-2 text-gray-700 hover:bg-gray-200"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigate({ to: "/multiplayer" });
|
void navigate({ to: "/multiplayer" });
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
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 (
|
|
||||||
<div className="ml-auto">
|
|
||||||
{session ? (
|
|
||||||
<button
|
|
||||||
onClick={handleSignOut}
|
|
||||||
className="text-sm text-(--color-text-muted) transition-colors duration-200
|
|
||||||
hover:text-(--color-primary)"
|
|
||||||
>
|
|
||||||
Sign out{" "}
|
|
||||||
<span className="text-(--color-accent)">{session.user.name}</span>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<Link
|
|
||||||
to="/login"
|
|
||||||
className="text-sm font-medium px-4 py-1.5 rounded-full
|
|
||||||
text-white bg-(--color-primary)
|
|
||||||
hover:bg-(--color-primary-dark)
|
|
||||||
transition-colors duration-200"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NavAuth;
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import NavAuth from "./NavAuth";
|
|
||||||
import NavLinks from "./NavLinks";
|
|
||||||
|
|
||||||
const Navbar = () => {
|
|
||||||
return (
|
|
||||||
<header className="sticky top-0 z-50 w-full bg-(--color-surface) border-b border-(--color-primary-light)">
|
|
||||||
<div className="max-w-5xl mx-auto px-6 h-14 flex items-center gap-8">
|
|
||||||
<span className="text-sm font-bold tracking-tight text-(--color-primary)">
|
|
||||||
lila
|
|
||||||
</span>
|
|
||||||
<NavLinks />
|
|
||||||
<NavAuth />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Navbar;
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
type NavLinkProps = { to: string; children: React.ReactNode };
|
|
||||||
|
|
||||||
const NavLink = ({ to, children }: NavLinkProps) => {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={to}
|
|
||||||
className="relative text-sm font-medium text-(--color-text-muted) transition-colors duration-200
|
|
||||||
hover:text-(--color-primary)
|
|
||||||
[&.active]:text-(--color-primary)
|
|
||||||
[&.active]:after:absolute
|
|
||||||
[&.active]:after:-bottom-1
|
|
||||||
[&.active]:after:left-0
|
|
||||||
[&.active]:after:w-full
|
|
||||||
[&.active]:after:h-0.5
|
|
||||||
[&.active]:after:bg-(--color-accent)
|
|
||||||
[&.active]:after:rounded-full
|
|
||||||
[&.active]:after:content-['']"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NavLink;
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import NavLink from "./NavLink";
|
|
||||||
|
|
||||||
const links = [
|
|
||||||
{ to: "/", label: "Home" },
|
|
||||||
{ to: "/play", label: "Play" },
|
|
||||||
{ to: "/multiplayer", label: "Multiplayer" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const NavLinks = () => {
|
|
||||||
return (
|
|
||||||
<nav className="flex items-center gap-6">
|
|
||||||
{links.map(({ to, label }) => (
|
|
||||||
<NavLink key={to} to={to}>
|
|
||||||
{label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NavLinks;
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
const NavLogin = () => {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to="/login"
|
|
||||||
className="text-sm font-medium px-4 py-1.5 rounded-full
|
|
||||||
text-white bg-(--color-primary)
|
|
||||||
hover:bg-(--color-primary-dark)
|
|
||||||
transition-colors duration-200"
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NavLogin;
|
|
||||||
|
|
@ -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 (
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="text-sm text-(--color-text-muted) transition-colors duration-200
|
|
||||||
hover:text-(--color-primary)"
|
|
||||||
>
|
|
||||||
logout <span className="text-(--color-accent)">{name}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NavLogout;
|
|
||||||
|
|
@ -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<Piece[]>(() => {
|
|
||||||
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 (
|
|
||||||
<div
|
|
||||||
className={`pointer-events-none absolute inset-0 overflow-visible ${className ?? ""}`}
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
{pieces.map((p) => (
|
|
||||||
<span key={p.id} className="lila-confetti-piece" style={p.style} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,90 +1 @@
|
||||||
@import "tailwindcss";
|
@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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||||
import Navbar from "../components/navbar/NavBar";
|
import { useSession, signOut } from "../lib/auth-client";
|
||||||
|
|
||||||
const RootLayout = () => {
|
const RootLayout = () => {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Navbar />
|
<div className="p-2 flex gap-2 items-center">
|
||||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
<Link to="/" className="[&.active]:font-bold">
|
||||||
<Outlet />
|
Home
|
||||||
</main>
|
</Link>
|
||||||
|
<Link to="/play" className="[&.active]:font-bold">
|
||||||
|
Play
|
||||||
|
</Link>
|
||||||
|
<Link to="/multiplayer" className="[&.active]:font-bold">
|
||||||
|
Multiplayer
|
||||||
|
</Link>
|
||||||
|
<div className="ml-auto">
|
||||||
|
{session ? (
|
||||||
|
<button
|
||||||
|
className="text-sm text-gray-600 hover:text-gray-900"
|
||||||
|
onClick={() => {
|
||||||
|
void signOut()
|
||||||
|
.then(() => {
|
||||||
|
void navigate({ to: "/" });
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Sign out error:", err);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign out ({session.user.name})
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<Outlet />
|
||||||
<TanStackRouterDevtools />
|
<TanStackRouterDevtools />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,11 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
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 });
|
export const Route = createFileRoute("/")({ component: Index });
|
||||||
|
|
||||||
function Index() {
|
function Index() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="p-2 text-3xl text-amber-400">
|
||||||
<Hero />
|
<h3>Welcome Home!</h3>
|
||||||
<HowItWorks />
|
|
||||||
<FeatureCards />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ const LoginPage = () => {
|
||||||
<div className="flex flex-col items-center justify-center gap-4 p-8">
|
<div className="flex flex-col items-center justify-center gap-4 p-8">
|
||||||
<h1 className="text-2xl font-bold">sign in to lila</h1>
|
<h1 className="text-2xl font-bold">sign in to lila</h1>
|
||||||
<button
|
<button
|
||||||
className="w-64 rounded-2xl bg-(--color-text) px-4 py-3 text-white font-bold hover:opacity-90 shadow-sm hover:shadow-md transition-all"
|
className="w-64 rounded bg-gray-800 px-4 py-2 text-white hover:bg-gray-700"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void signIn
|
void signIn
|
||||||
.social({ provider: "github", callbackURL: window.location.origin })
|
.social({ provider: "github", callbackURL: window.location.origin })
|
||||||
|
|
@ -28,7 +28,7 @@ const LoginPage = () => {
|
||||||
Continue with GitHub
|
Continue with GitHub
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="w-64 rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) shadow-sm hover:shadow-md transition-all"
|
className="w-64 rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-500"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void signIn
|
void signIn
|
||||||
.social({ provider: "google", callbackURL: window.location.origin })
|
.social({ provider: "google", callbackURL: window.location.origin })
|
||||||
|
|
|
||||||
|
|
@ -112,9 +112,9 @@ function GamePage() {
|
||||||
// Phase: playing
|
// Phase: playing
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
||||||
<div className="w-full max-w-md rounded-3xl border border-(--color-primary-light) bg-white/50 dark:bg-black/10 backdrop-blur shadow-sm p-8 flex flex-col gap-6">
|
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
|
||||||
{/* Progress */}
|
{/* Progress */}
|
||||||
<p className="text-xs font-bold tracking-widest uppercase text-(--color-text-muted) text-center">
|
<p className="text-sm text-gray-500 text-center">
|
||||||
Question {currentQuestion.questionNumber} of{" "}
|
Question {currentQuestion.questionNumber} of{" "}
|
||||||
{currentQuestion.totalQuestions}
|
{currentQuestion.totalQuestions}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -150,7 +150,7 @@ function GamePage() {
|
||||||
{/* Round results */}
|
{/* Round results */}
|
||||||
{answerResult && (
|
{answerResult && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h3 className="text-sm font-black text-(--color-text)">
|
<h3 className="text-sm font-semibold text-gray-700">
|
||||||
Round results
|
Round results
|
||||||
</h3>
|
</h3>
|
||||||
{answerResult.players.map((player) => {
|
{answerResult.players.map((player) => {
|
||||||
|
|
@ -160,9 +160,9 @@ function GamePage() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={player.userId}
|
key={player.userId}
|
||||||
className="flex items-center justify-between text-sm text-(--color-text)"
|
className="flex items-center justify-between text-sm"
|
||||||
>
|
>
|
||||||
<span className="font-semibold">{player.user.name}</span>
|
<span className="text-gray-700">{player.user.name}</span>
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
result?.isCorrect
|
result?.isCorrect
|
||||||
|
|
@ -176,9 +176,7 @@ function GamePage() {
|
||||||
? "✓ Correct"
|
? "✓ Correct"
|
||||||
: "✗ Wrong"}
|
: "✗ Wrong"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-(--color-text-muted)">
|
<span className="text-gray-500">{player.score} pts</span>
|
||||||
{player.score} pts
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,8 @@ function MultiplayerPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
||||||
<div className="w-full max-w-md rounded-3xl border border-(--color-primary-light) bg-white/50 dark:bg-black/10 backdrop-blur shadow-sm p-8 flex flex-col gap-6">
|
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
|
||||||
<h1 className="text-2xl font-black tracking-tight text-center text-(--color-text)">
|
<h1 className="text-2xl font-bold text-center text-purple-800">
|
||||||
Multiplayer
|
Multiplayer
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
|
@ -85,14 +85,14 @@ function MultiplayerPage() {
|
||||||
|
|
||||||
{/* Create lobby */}
|
{/* Create lobby */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h2 className="text-lg font-bold text-(--color-text)">
|
<h2 className="text-lg font-semibold text-gray-700">
|
||||||
Create a lobby
|
Create a lobby
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-(--color-text-muted)">
|
<p className="text-sm text-gray-500">
|
||||||
Start a new game and invite friends with a code.
|
Start a new game and invite friends with a code.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
className="rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-black hover:bg-(--color-primary-dark) shadow-sm hover:shadow-md transition-all disabled:opacity-50"
|
className="rounded bg-purple-600 px-4 py-2 text-white hover:bg-purple-500 disabled:opacity-50"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void handleCreate().catch((err) => {
|
void handleCreate().catch((err) => {
|
||||||
console.error("Create lobby error:", err);
|
console.error("Create lobby error:", err);
|
||||||
|
|
@ -104,16 +104,16 @@ function MultiplayerPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-(--color-primary-light) opacity-60" />
|
<div className="border-t border-gray-200" />
|
||||||
|
|
||||||
{/* Join lobby */}
|
{/* Join lobby */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h2 className="text-lg font-bold text-(--color-text)">Join a lobby</h2>
|
<h2 className="text-lg font-semibold text-gray-700">Join a lobby</h2>
|
||||||
<p className="text-sm text-(--color-text-muted)">
|
<p className="text-sm text-gray-500">
|
||||||
Enter the code shared by your host.
|
Enter the code shared by your host.
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
className="rounded-2xl border border-(--color-primary-light) bg-white/30 dark:bg-black/10 px-4 py-3 text-sm uppercase tracking-widest text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
|
className="rounded border border-gray-300 px-3 py-2 text-sm uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-purple-400"
|
||||||
placeholder="Enter code (e.g. WOLF42)"
|
placeholder="Enter code (e.g. WOLF42)"
|
||||||
value={joinCode}
|
value={joinCode}
|
||||||
onChange={(e) => setJoinCode(e.target.value)}
|
onChange={(e) => setJoinCode(e.target.value)}
|
||||||
|
|
@ -128,7 +128,7 @@ function MultiplayerPage() {
|
||||||
disabled={isCreating || isJoining}
|
disabled={isCreating || isJoining}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="rounded-2xl bg-(--color-surface) border border-(--color-primary-light) px-4 py-3 text-(--color-text) font-black hover:bg-white/30 dark:hover:bg-black/10 shadow-sm hover:shadow-md transition-all disabled:opacity-50"
|
className="rounded bg-gray-800 px-4 py-2 text-white hover:bg-gray-700 disabled:opacity-50"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void handleJoin().catch((err) => {
|
void handleJoin().catch((err) => {
|
||||||
console.error("Join lobby error:", err);
|
console.error("Join lobby error:", err);
|
||||||
|
|
|
||||||
|
|
@ -88,14 +88,12 @@ function LobbyPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
||||||
<div className="w-full max-w-md rounded-3xl border border-(--color-primary-light) bg-white/50 dark:bg-black/10 backdrop-blur shadow-sm p-8 flex flex-col gap-6">
|
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
|
||||||
{/* Lobby code */}
|
{/* Lobby code */}
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<p className="text-xs font-bold tracking-widest uppercase text-(--color-text-muted)">
|
<p className="text-sm text-gray-500">Lobby code</p>
|
||||||
Lobby code
|
|
||||||
</p>
|
|
||||||
<button
|
<button
|
||||||
className="text-4xl font-black tracking-widest text-(--color-text) hover:text-(--color-primary) cursor-pointer"
|
className="text-4xl font-bold tracking-widest text-purple-800 hover:text-purple-600 cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigator.clipboard.writeText(code);
|
void navigator.clipboard.writeText(code);
|
||||||
}}
|
}}
|
||||||
|
|
@ -103,21 +101,21 @@ function LobbyPage() {
|
||||||
>
|
>
|
||||||
{code}
|
{code}
|
||||||
</button>
|
</button>
|
||||||
<p className="text-xs text-(--color-text-muted)">Click to copy</p>
|
<p className="text-xs text-gray-400">Click to copy</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-(--color-primary-light) opacity-60" />
|
<div className="border-t border-gray-200" />
|
||||||
|
|
||||||
{/* Player list */}
|
{/* Player list */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h2 className="text-lg font-black text-(--color-text)">
|
<h2 className="text-lg font-semibold text-gray-700">
|
||||||
Players ({lobby.players.length})
|
Players ({lobby.players.length})
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="flex flex-col gap-1">
|
<ul className="flex flex-col gap-1">
|
||||||
{lobby.players.map((player) => (
|
{lobby.players.map((player) => (
|
||||||
<li
|
<li
|
||||||
key={player.userId}
|
key={player.userId}
|
||||||
className="flex items-center gap-2 text-sm text-(--color-text)"
|
className="flex items-center gap-2 text-sm text-gray-700"
|
||||||
>
|
>
|
||||||
<span className="w-2 h-2 rounded-full bg-green-400" />
|
<span className="w-2 h-2 rounded-full bg-green-400" />
|
||||||
{player.user.name}
|
{player.user.name}
|
||||||
|
|
@ -137,7 +135,7 @@ function LobbyPage() {
|
||||||
{/* Start button — host only */}
|
{/* Start button — host only */}
|
||||||
{isHost && (
|
{isHost && (
|
||||||
<button
|
<button
|
||||||
className="rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-black hover:bg-(--color-primary-dark) shadow-sm hover:shadow-md transition-all disabled:opacity-50"
|
className="rounded bg-purple-600 px-4 py-2 text-white hover:bg-purple-500 disabled:opacity-50"
|
||||||
onClick={handleStart}
|
onClick={handleStart}
|
||||||
disabled={!canStart}
|
disabled={!canStart}
|
||||||
>
|
>
|
||||||
|
|
@ -151,7 +149,7 @@ function LobbyPage() {
|
||||||
|
|
||||||
{/* Non-host waiting message */}
|
{/* Non-host waiting message */}
|
||||||
{!isHost && (
|
{!isHost && (
|
||||||
<p className="text-sm text-(--color-text-muted) text-center">
|
<p className="text-sm text-gray-500 text-center">
|
||||||
Waiting for host to start the game...
|
Waiting for host to start the game...
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -225,59 +225,9 @@ Host git.lilastudy.com
|
||||||
|
|
||||||
This allows standard git commands without specifying the port.
|
This allows standard git commands without specifying the port.
|
||||||
|
|
||||||
## CI/CD Pipeline
|
|
||||||
|
|
||||||
Automated build and deploy via Forgejo Actions. On every push to `main`, the pipeline builds ARM64 images natively on the VPS, pushes them to the Forgejo registry, and restarts the app containers.
|
|
||||||
|
|
||||||
### Components
|
|
||||||
|
|
||||||
- **Forgejo Actions** — enabled by default, workflow files in `.forgejo/workflows/`
|
|
||||||
- **Forgejo Runner** — runs as a container (`lila-ci-runner`) on the VPS, uses the host's Docker socket to build images natively on ARM64
|
|
||||||
- **Workflow file** — `.forgejo/workflows/deploy.yml`
|
|
||||||
|
|
||||||
### Pipeline Steps
|
|
||||||
|
|
||||||
1. Install Docker CLI and SSH client in the job container
|
|
||||||
2. Checkout the repository
|
|
||||||
3. Login to the Forgejo container registry
|
|
||||||
4. Build API image (target: `runner`)
|
|
||||||
5. Build Web image (target: `production`, with `VITE_API_URL` baked in)
|
|
||||||
6. Push both images to `git.lilastudy.com`
|
|
||||||
7. SSH into the VPS, pull new images, restart `api` and `web` containers, prune old images
|
|
||||||
|
|
||||||
### Secrets (stored in Forgejo repo settings → Actions → Secrets)
|
|
||||||
|
|
||||||
| Secret | Value |
|
|
||||||
|---|---|
|
|
||||||
| REGISTRY_USER | Forgejo username |
|
|
||||||
| REGISTRY_PASSWORD | Forgejo password |
|
|
||||||
| SSH_PRIVATE_KEY | Contents of `~/.ssh/ci-runner` on the VPS |
|
|
||||||
| SSH_HOST | VPS IP address |
|
|
||||||
| SSH_USER | `lila` |
|
|
||||||
|
|
||||||
### Runner Configuration
|
|
||||||
|
|
||||||
The runner config is at `/data/config.yml` inside the `lila-ci-runner` container. Key settings:
|
|
||||||
|
|
||||||
- `docker_host: "automount"` — mounts the host Docker socket into job containers
|
|
||||||
- `valid_volumes: ["/var/run/docker.sock"]` — allows the socket mount
|
|
||||||
- `privileged: true` — required for Docker access from job containers
|
|
||||||
- `options: "--group-add 989"` — adds the host's docker group (GID 989) to job containers
|
|
||||||
|
|
||||||
The runner command must explicitly reference the config file:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
command: '/bin/sh -c "sleep 5; forgejo-runner -c /data/config.yml daemon"'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Deploy Cycle
|
|
||||||
|
|
||||||
Push to main → pipeline runs automatically (~2-5 min) → app is updated. No manual steps required.
|
|
||||||
|
|
||||||
To manually trigger a re-run: go to the repo's Actions tab, click on the latest run, and use the re-run button.
|
|
||||||
|
|
||||||
## Known Issues and Future Work
|
## Known Issues and Future Work
|
||||||
|
|
||||||
|
- **CI/CD**: Currently manual build-push-pull cycle. Plan: Forgejo Actions with a runner on the VPS building ARM images natively (eliminates QEMU cross-compilation)
|
||||||
- **Backups**: Offsite backup storage (Hetzner Object Storage or similar) should be added
|
- **Backups**: Offsite backup storage (Hetzner Object Storage or similar) should be added
|
||||||
- **Valkey**: Not in the production stack yet. Will be added when multiplayer requires session/room state
|
- **Valkey**: Not in the production stack yet. Will be added when multiplayer requires session/room state
|
||||||
- **Monitoring/logging**: No centralized logging or uptime monitoring configured
|
- **Monitoring/logging**: No centralized logging or uptime monitoring configured
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
# design
|
|
||||||
|
|
||||||
## notes
|
|
||||||
|
|
||||||
break points
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
|
|
||||||
- pinning dependencies in package.json files
|
- pinning dependencies in package.json files
|
||||||
- rethink organisation of datafiles and wordlists
|
- rethink organisation of datafiles and wordlists
|
||||||
- admin dashboard for user management, also overview of words and languages and all their stats
|
|
||||||
|
|
||||||
## problems+thoughts
|
## problems+thoughts
|
||||||
|
|
||||||
|
|
@ -29,18 +28,6 @@ laptop: verify if docker containers run on startup (they shouldnt)
|
||||||
### vps setup
|
### vps setup
|
||||||
|
|
||||||
- monitoring and logging (eg via chrootkit or rkhunter, logwatch/monit => mails daily with summary)
|
- monitoring and logging (eg via chrootkit or rkhunter, logwatch/monit => mails daily with summary)
|
||||||
<<<<<<< HEAD
|
|
||||||
- ~~keep the vps clean (e.g. old docker images/containers)~~ ✅ CI/CD pipeline runs `docker image prune -f` after deploy
|
|
||||||
|
|
||||||
### ~~cd/ci pipeline~~ ✅ RESOLVED
|
|
||||||
|
|
||||||
Forgejo Actions with runner on VPS, Forgejo built-in container registry. See `deployment.md`.
|
|
||||||
|
|
||||||
### ~~postgres backups~~ ✅ RESOLVED
|
|
||||||
|
|
||||||
Daily pg_dump cron job, 7-day retention, dev laptop auto-sync via rsync. See `deployment.md`.
|
|
||||||
=======
|
|
||||||
>>>>>>> dev
|
|
||||||
|
|
||||||
### try now option
|
### try now option
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ Each phase produces a working increment. Nothing is built speculatively.
|
||||||
- [x] Configure Drizzle ORM + connection to local PostgreSQL
|
- [x] Configure Drizzle ORM + connection to local PostgreSQL
|
||||||
- [x] Write first migration (empty — validates the pipeline works)
|
- [x] Write first migration (empty — validates the pipeline works)
|
||||||
- [x] `docker-compose.yml` for local dev: `api`, `web`, `postgres`, `valkey`
|
- [x] `docker-compose.yml` for local dev: `api`, `web`, `postgres`, `valkey`
|
||||||
- [x] Root `.env.example` for local dev (`docker-compose.yml` + API)
|
- [x] `.env.example` files for `apps/api` and `apps/web`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -176,36 +176,37 @@ _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.
|
**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.
|
**Done when:** Two browser tabs can join the same room and see each other's display names update live via WebSocket.
|
||||||
|
|
||||||
- [x] Write Drizzle schema: `lobbies`, `lobby_players`
|
- [ ] Write Drizzle schema: `rooms`, `room_players`
|
||||||
- [x] Write and run migration
|
- [ ] Write and run migration
|
||||||
- [x] `POST /api/v1/lobbies` and `POST /api/v1/lobbies/:code/join` REST endpoints
|
- [ ] `POST /rooms` and `POST /rooms/:code/join` REST endpoints
|
||||||
- [x] `LobbyService`: create lobby with Crockford Base32 code, join lobby, enforce max player limit
|
- [ ] `RoomService`: create room with short code, join room, enforce max player limit
|
||||||
- [x] WebSocket server: attach `ws` upgrade handler to Express HTTP server
|
- [ ] WebSocket server: attach `ws` upgrade handler to Express HTTP server
|
||||||
- [x] WS auth middleware: validate Better Auth session on upgrade
|
- [ ] WS auth middleware: validate JWT on upgrade
|
||||||
- [x] WS message router: dispatch by `type` via Zod discriminated union
|
- [ ] WS message router: dispatch by `type`
|
||||||
- [x] `lobby:join` / `lobby:leave` handlers → broadcast `lobby:state`
|
- [ ] `room:join` / `room:leave` handlers → broadcast `room:state`
|
||||||
- [x] Lobby membership tracked in PostgreSQL (durable), game state in-memory (Valkey deferred)
|
- [ ] Room membership tracked in Valkey (ephemeral) + PostgreSQL (durable)
|
||||||
- [x] Define all WS event Zod schemas in `packages/shared`
|
- [ ] Define all WS event Zod schemas in `packages/shared`
|
||||||
- [x] Frontend: `/multiplayer` — create lobby + join-by-code
|
- [ ] Frontend: `/multiplayer/lobby` — create room + join-by-code
|
||||||
- [x] Frontend: `/multiplayer/lobby/:code` — player list, lobby code, "Start Game" (host only)
|
- [ ] Frontend: `/multiplayer/room/:code` — player list, room code, "Start Game" (host only)
|
||||||
- [x] Frontend: WS client class with typed message handlers
|
- [ ] Frontend: WS client singleton with reconnect
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 5 — Multiplayer Game
|
## Phase 5 — Multiplayer Game
|
||||||
|
|
||||||
**Goal:** Host starts a game; all players answer simultaneously in real time; a winner is declared.
|
**Goal:** Host starts a game; all players answer simultaneously in real time; a winner is declared.
|
||||||
**Done when:** 2–4 players complete a 3-round game with correct live scores and a winner screen.
|
**Done when:** 2–4 players complete a 10-round game with correct live scores and a winner screen.
|
||||||
|
|
||||||
- [x] `MultiplayerGameService`: generate question sequence, enforce 15s server timer
|
- [ ] `GameService`: generate question sequence, enforce 15s server timer
|
||||||
- [x] `lobby:start` WS handler → broadcast first `game:question`
|
- [ ] `room:start` WS handler → broadcast first `game:question`
|
||||||
- [x] `game:answer` WS handler → collect per-player answers
|
- [ ] `game:answer` WS handler → collect per-player answers
|
||||||
- [x] On all-answered or timeout → evaluate, broadcast `game:answer_result`
|
- [ ] On all-answered or timeout → evaluate, broadcast `game:answer_result`
|
||||||
- [x] After N rounds → broadcast `game:finished`, update DB (transactional)
|
- [ ] After N rounds → broadcast `game:finished`, update DB (transactional)
|
||||||
- [x] Frontend: `/multiplayer/game/:code` route
|
- [ ] Frontend: `/multiplayer/game/:code` route
|
||||||
- [x] Frontend: reuse `QuestionCard` + `OptionButton`; round results per player
|
- [ ] Frontend: reuse `QuestionCard` + `OptionButton`; add countdown timer
|
||||||
- [x] Frontend: `MultiplayerScoreScreen` — winner highlight, final scores, play again
|
- [ ] Frontend: `ScoreBoard` component — live per-player scores
|
||||||
- [x] Unit tests for `LobbyService`, WS auth, WS router
|
- [ ] Frontend: `GameFinished` screen — winner highlight, final scores, play again
|
||||||
|
- [ ] Unit tests for `GameService` (round evaluation, tie-breaking, timeout)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -235,7 +236,7 @@ Phase 0 (Foundation) ✅
|
||||||
└── Phase 2 (Singleplayer UI) ✅
|
└── Phase 2 (Singleplayer UI) ✅
|
||||||
├── Phase 3 (Auth) ✅
|
├── Phase 3 (Auth) ✅
|
||||||
│ └── Phase 6 (Deployment + CI/CD) ✅
|
│ └── Phase 6 (Deployment + CI/CD) ✅
|
||||||
└── Phase 4 (Multiplayer Lobby) ✅
|
└── Phase 4 (Multiplayer Lobby)
|
||||||
└── Phase 5 (Multiplayer Game) ✅
|
└── Phase 5 (Multiplayer Game)
|
||||||
└── Phase 7 (Hardening)
|
└── Phase 7 (Hardening)
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
## 1. Project Overview
|
## 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 app supports both singleplayer and real-time multiplayer game modes.
|
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.
|
||||||
|
|
||||||
**The core learning loop:**
|
**The core learning loop:**
|
||||||
Show word → pick answer → see result → next word → final score
|
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
|
- 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
|
- 1000+ English–Italian nouns seeded from WordNet
|
||||||
|
|
||||||
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.
|
This is the full vision. The MVP deliberately ignores most of it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. MVP Scope
|
## 3. MVP Scope
|
||||||
|
|
||||||
**Goal:** A working, presentable vocabulary trainer that can be shown to real people (singleplayer and multiplayer), with a production deployment.
|
**Goal:** A working, presentable singleplayer quiz that can be shown to real people.
|
||||||
|
|
||||||
### What is IN the MVP
|
### What is IN the MVP
|
||||||
|
|
||||||
|
|
@ -45,14 +45,16 @@ This is the full vision. The current implementation already covers most of it; r
|
||||||
- Clean, mobile-friendly UI (Tailwind + shadcn/ui)
|
- Clean, mobile-friendly UI (Tailwind + shadcn/ui)
|
||||||
- Global error handler with typed error classes
|
- Global error handler with typed error classes
|
||||||
- Unit + integration tests for the API
|
- Unit + integration tests for the API
|
||||||
- Authentication via Better Auth (Google + GitHub)
|
- Local dev only (no deployment for MVP)
|
||||||
- Multiplayer lobby + game over WebSockets
|
|
||||||
- Production deployment (Docker Compose + Caddy + Hetzner) and CI/CD (Forgejo Actions)
|
|
||||||
|
|
||||||
### What is CUT from the MVP
|
### What is CUT from the MVP
|
||||||
|
|
||||||
| Feature | Why cut |
|
| 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 |
|
| 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).
|
These are not deleted from the plan — they are deferred. The architecture is already designed to support them. See Section 11 (Post-MVP Ladder).
|
||||||
|
|
@ -78,15 +80,15 @@ The monorepo structure and tooling are already set up. This is the full stack.
|
||||||
| Auth | Better Auth (Google + GitHub) | ✅ |
|
| Auth | Better Auth (Google + GitHub) | ✅ |
|
||||||
| Deployment | Docker Compose, Caddy, Hetzner | ✅ |
|
| Deployment | Docker Compose, Caddy, Hetzner | ✅ |
|
||||||
| CI/CD | Forgejo Actions | ✅ |
|
| CI/CD | Forgejo Actions | ✅ |
|
||||||
| Realtime | WebSockets (`ws` library) | ✅ |
|
| Realtime | WebSockets (`ws` library) | ❌ post-MVP |
|
||||||
| Cache | Valkey | ⚠️ optional (used locally; production/state hardening) |
|
| Cache | Valkey | ❌ post-MVP |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Repository Structure
|
## 5. Repository Structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
lila/
|
vocab-trainer/
|
||||||
├── .forgejo/
|
├── .forgejo/
|
||||||
│ └── workflows/
|
│ └── workflows/
|
||||||
│ └── deploy.yml — CI/CD pipeline (build, push, deploy)
|
│ └── deploy.yml — CI/CD pipeline (build, push, deploy)
|
||||||
|
|
@ -152,6 +154,7 @@ lila/
|
||||||
├── scripts/ — Python extraction/comparison/merge scripts
|
├── scripts/ — Python extraction/comparison/merge scripts
|
||||||
├── documentation/ — project docs
|
├── documentation/ — project docs
|
||||||
├── docker-compose.yml — local dev stack
|
├── docker-compose.yml — local dev stack
|
||||||
|
├── docker-compose.prod.yml — production config reference
|
||||||
├── Caddyfile — reverse proxy routing
|
├── Caddyfile — reverse proxy routing
|
||||||
└── pnpm-workspace.yaml
|
└── pnpm-workspace.yaml
|
||||||
```
|
```
|
||||||
|
|
@ -287,27 +290,15 @@ After completing a task: share the code, ask what to refactor and why. The LLM s
|
||||||
|
|
||||||
## 11. Post-MVP Ladder
|
## 11. Post-MVP Ladder
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
| Phase | What it adds | Status |
|
|
||||||
| ----------------- | ------------------------------------------------------------------------------- | ------ |
|
|
||||||
| Auth | Better Auth (Google + GitHub), embedded in Express API, user rows in DB | ✅ |
|
|
||||||
| Deployment | Docker Compose, Caddy, Forgejo, CI/CD, Hetzner VPS | ✅ |
|
|
||||||
| Hardening (partial) | CI/CD pipeline, DB backups | ✅ |
|
|
||||||
| User Stats | Games played, score history, profile page | ❌ |
|
|
||||||
| Multiplayer Lobby | Room creation, join by code, WebSocket connection | ❌ |
|
|
||||||
| Multiplayer Game | Simultaneous answers, server timer, live scores, winner screen | ❌ |
|
|
||||||
| Hardening (rest) | Rate limiting, error boundaries, monitoring, accessibility | ❌ |
|
|
||||||
=======
|
|
||||||
| Phase | What it adds | Status |
|
| Phase | What it adds | Status |
|
||||||
| ------------------- | ----------------------------------------------------------------------- | ------ |
|
| ------------------- | ----------------------------------------------------------------------- | ------ |
|
||||||
| Auth | Better Auth (Google + GitHub), embedded in Express API, user rows in DB | ✅ |
|
| Auth | Better Auth (Google + GitHub), embedded in Express API, user rows in DB | ✅ |
|
||||||
| Deployment | Docker Compose, Caddy, Forgejo, CI/CD, Hetzner VPS | ✅ |
|
| Deployment | Docker Compose, Caddy, Forgejo, CI/CD, Hetzner VPS | ✅ |
|
||||||
| Hardening (partial) | CI/CD pipeline, DB backups | ✅ |
|
| Hardening (partial) | CI/CD pipeline, DB backups | ✅ |
|
||||||
| User Stats | Games played, score history, profile page | ❌ |
|
| User Stats | Games played, score history, profile page | ❌ |
|
||||||
| Multiplayer Lobby | Room creation, join by code, WebSocket connection | ✅ |
|
| Multiplayer Lobby | Room creation, join by code, WebSocket connection | ❌ |
|
||||||
| Multiplayer Game | Simultaneous answers, server timer, live scores, winner screen | ✅ |
|
| Multiplayer Game | Simultaneous answers, server timer, live scores, winner screen | ❌ |
|
||||||
| Hardening (rest) | Rate limiting, error boundaries, monitoring, accessibility | ❌ |
|
| Hardening (rest) | Rate limiting, error boundaries, monitoring, accessibility | ❌ |
|
||||||
>>>>>>> dev
|
|
||||||
|
|
||||||
### Future Data Model Extensions (deferred, additive)
|
### Future Data Model Extensions (deferred, additive)
|
||||||
|
|
||||||
|
|
@ -320,20 +311,12 @@ 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.
|
All are new tables referencing existing `terms` rows via FK. No existing schema changes required.
|
||||||
|
|
||||||
### Multiplayer Architecture (current + deferred)
|
### Multiplayer Architecture (deferred)
|
||||||
|
|
||||||
**Implemented now:**
|
- WebSocket protocol: `ws` library, Zod discriminated union for message types
|
||||||
|
- Room model: human-readable codes (e.g. `WOLF-42`), not matchmaking queue
|
||||||
- WebSocket protocol uses the `ws` library with a Zod discriminated union for message types (defined in `packages/shared`)
|
- Game mechanic: simultaneous answers, 15-second server timer, all players see same question
|
||||||
- Room model uses human-readable codes (no matchmaking queue)
|
- Valkey for ephemeral room state, PostgreSQL for durable records
|
||||||
- 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)
|
### Infrastructure (current)
|
||||||
|
|
||||||
|
|
@ -348,7 +331,7 @@ See `deployment.md` for full infrastructure documentation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. Definition of Done (Current Baseline)
|
## 12. Definition of Done (MVP)
|
||||||
|
|
||||||
- [x] API returns quiz terms with correct distractors
|
- [x] API returns quiz terms with correct distractors
|
||||||
- [x] User can complete a quiz without errors
|
- [x] User can complete a quiz without errors
|
||||||
|
|
@ -357,9 +340,6 @@ See `deployment.md` for full infrastructure documentation.
|
||||||
- [x] No hardcoded data — everything comes from the database
|
- [x] No hardcoded data — everything comes from the database
|
||||||
- [x] Global error handler with typed error classes
|
- [x] Global error handler with typed error classes
|
||||||
- [x] Unit + integration tests for API
|
- [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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -375,8 +355,8 @@ Phase 0 (Foundation) ✅
|
||||||
└── Phase 2 (Singleplayer UI) ✅
|
└── Phase 2 (Singleplayer UI) ✅
|
||||||
├── Phase 3 (Auth) ✅
|
├── Phase 3 (Auth) ✅
|
||||||
│ └── Phase 6 (Deployment + CI/CD) ✅
|
│ └── Phase 6 (Deployment + CI/CD) ✅
|
||||||
└── Phase 4 (Multiplayer Lobby) ✅
|
└── Phase 4 (Multiplayer Lobby)
|
||||||
└── Phase 5 (Multiplayer Game) ✅
|
└── Phase 5 (Multiplayer Game)
|
||||||
└── Phase 7 (Hardening)
|
└── Phase 7 (Hardening)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
280
scripts/create-issues.sh
Normal file
280
scripts/create-issues.sh
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
#!/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 ==="
|
||||||
Loading…
Add table
Add a link
Reference in a new issue