A Language learning app that tracks your progress
TypeScript
2
172 commits
updated Aug 19, 2026
A focused language learning app built with React + Express. Adaptive CEFR progression, persistent learner data, and a variety of exercise types across seven languages.
Hover over any word in a reverse-translation exercise (translating to English) or in a cloze sentence to see a tooltip. Three sources are checked in priority order:
'word' = meaning pattern in the exercise hints array.wordGlossary field on the exercise.word_translations SQLite cache first (populated at startup from the content files), then calls a self-hosted LibreTranslate instance. Each unique word is looked up at most once and cached permanently.At server startup, the word_translations table is wiped and rebuilt from authoritative content sources: wordGlossary fields, "Vocabulary: X" flashcard pairs, and single-word hint patterns. This ensures known exercise vocabulary is always translated correctly without any API call.
Set LIBRETRANSLATE_URL and LIBRETRANSLATE_API_KEY in server/.env to enable the API fallback. Self-hosting with Docker:
# Start a LibreTranslate instance with API key support
docker run -it -p 5000:5000 libretranslate/libretranslate --api-keys
# Generate a key (in a second terminal)
docker exec <container-id> ltmanage keys add
Fetched translations are validated before caching: punctuation-only responses and strings identical to the source word are discarded. Translations are normalized — trailing punctuation stripped, ALL-CAPS lowercased, first character lowercased.
The repo also includes an interactive TypeScript utility for generating new language content from English via LibreTranslate:
npm run translate:language
The tool reads server/.env, lets you select a supported target language and category files, rate-limits API calls if needed, and writes only new files under server/content/languages/<language>/. It never overwrites existing category files.
The wizard first asks what to generate — course categories, the practice word pool, or Story Reader stories. Choosing practice words translates the English word list in server/content/practice_words/_template.json into the target language, writing server/content/practice_words/<language>.json. Translations are batched (TRANSLATE_BATCH_SIZE words per request), so a ~1000-word pool costs roughly 20 API calls rather than one per word.
Choosing Stories reads the hand-authored server/content/stories/english.json, translates each sentence and title English → target, then runs a reverse target → English pass to build a per-word glossary. Each glossary entry borrows its part-of-speech (and an exact-match grammar note) from the English source glossary, and the file is written in the same compact one-line-per-entry layout as the hand-authored stories. Output goes to server/content/stories/<language>.json. As with all jobs it never overwrites an existing file, so delete a language's existing story file first if you want to regenerate it from the longer English source.
To stop MT from carrying English proper nouns straight through (e.g. a Spanish story still set in London), scripts/libretranslate/story-localization.json provides per-culture overrides keyed by source story id. terms swap culturally-specific words — place names and currency — so each language gets a native setting (the B1 letter becomes Barcelona / Paris / Berlin / Rome / Stockholm), and culturalNote replaces the explanatory note with one describing the target culture. The native spelling (e.g. Roma) is fed to MT and kept out of the glossary, while the English exonym (Rome) is shown as the reference; anything omitted from the map falls back to the English source.
The generator code is split for clarity under scripts/libretranslate/: terminal-menu.ts (generic interactive prompts), content-generator.ts (translation + JSON file IO), and index.ts (the wizard that wires them together).
Content authors can add curated per-word translations to any exercise via a wordGlossary object:
{
"id": "ru-gr-a1-s02",
"correctAnswer": "Вчера я занимался два часа.",
"hints": ["'Вчера' = yesterday; leads the sentence naturally."],
"wordGlossary": {
"вчера": "yesterday",
"я": "I",
"занимался": "studied / was studying",
"два": "two",
"часа": "hours (gen. sg.)"
}
}
Keys are lowercased word forms exactly as they appear in correctAnswer; values are short English glosses. The field is optional and validated at server startup.
1–4 select a multiple-choice option, Enter submitsVisible only to the first registered user and anyone listed in CONTRIBUTION_REVIEWER_EMAILS.
| Layer | Technology |
|---|---|
| Frontend | React 19, Vite, TypeScript |
| Backend | Express 5, TypeScript (strip-types) |
| Database | SQLite (better-sqlite3) or PostgreSQL / Neon (pg) |
| Auth | JWT (30-day TTL), bcrypt, Google OAuth2 |
| Charts | Hand-rolled SVG — no chart library |
| Tests | Vitest (client), Node test runner (server) |
LingoFlow runs on either SQLite or PostgreSQL. The driver is selected at startup:
| Condition | Driver |
|---|---|
DATABASE_URL is set | PostgreSQL (pg) |
DATABASE_URL is unset | SQLite (better-sqlite3), at server/data/lingoflow.db |
LINGOFLOW_DB_DRIVER=sqlite|postgres | Overrides the above |
SQLite is the default for local development and is what the test suite always runs against, so no database server is needed to work on the app. Postgres (Neon) is intended for hosted deployments.
There is one copy of every query. server/src/db.ts writes dialect-neutral SQL with
? placeholders; server/src/db/dialect.ts translates it for Postgres (identity
columns, timestamp defaults, ? → $1..$n). Schema creation and migrations run once
at startup through initSchema() and are idempotent on both backends.
Put the pooled connection string in server/.env:
DATABASE_URL=postgresql://user:pass@ep-xxx-pooler.region.aws.neon.tech/db?sslmode=require
Start the server once and it creates the schema.
npm run db:import-sqlite # from server/data/lingoflow.db
npm run db:import-sqlite -- --from ./backup.db # from somewhere else
npm run db:import-sqlite -- --dry-run # report row counts only
The import copies all tables in FK-safe order, resyncs identity sequences afterwards (so the next insert does not collide on a primary key), and is safe to re-run — existing rows are reported as skipped.
scripts/repair-xp.tsandscripts/set-xp.tsopen the SQLite file directly and are SQLite-only maintenance tools; they do not work against Postgres.
npm install
npm run dev
http://localhost:5173http://localhost:4000/apiCreate server/.env (copy server/.env.example) for local development.
| Variable | Side | Description |
|---|---|---|
LINGOFLOW_AUTH_SECRET | server | JWT signing secret — change in production |
GOOGLE_OAUTH_CLIENT_ID | server | Google OAuth2 web client ID |
GOOGLE_OAUTH_CLIENT_SECRET | server | Google OAuth2 web client secret |
GOOGLE_OAUTH_REDIRECT_URI | server | Callback URL registered in Google console (default: http://localhost:4000/api/auth/google/callback) |
PUBLIC_APP_URL | server | Base URL used in verification/reset emails (e.g. https://app.example.com) |
RESEND_API_KEY | server | Preferred email delivery — Resend HTTPS API. Takes priority over SMTP when set |
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_SECURE, EMAIL_FROM | server | SMTP fallback for transactional emails, used when RESEND_API_KEY is unset. EMAIL_FROM is used for both delivery paths |
CONTRIBUTION_REVIEWER_EMAILS | server | Comma-separated moderator email list |
LOG_LEVEL | server | debug | info | warn | error (default: info) |
VITE_API_BASE | client | API base path/URL (default: /api) |
Admin access: the first registered user (id = 1) is automatically an admin. Additional admins are granted via CONTRIBUTION_REVIEWER_EMAILS.
npm run build # bundles client into server/dist/client
npm run start # serves everything from http://localhost:4000
Or build the server bundle separately:
npm run build --prefix server # typecheck + minified bundle → server/dist/index.js
npm run start:dist --prefix server
Do not run npm update on the server — that resolves to newer versions and rewrites the lockfile, which is non-reproducible. Update dependencies locally, commit the single root lockfile (npm workspaces — server/ and client/ share it, there are no per-workspace lockfiles), then install from it on the server with npm ci (a clean, reproducible install that fails if package.json and the lockfile disagree).
git pull
npm ci
npm run build # bundles client into server/dist/client
# then restart the service (pm2 / systemd / etc.)
If you build the artifacts in CI and ship dist/ to the server, the runtime box only needs production deps:
npm ci --omit=dev
Otherwise keep dev dependencies — the build tools (vite, esbuild, tsc) live under devDependencies.
Native modules: never copy node_modules from another machine. better-sqlite3 is a native module whose binary is platform- and ABI-specific; npm ci fetches the correct prebuilt binary for the server's OS and Node version. The lockfile itself is platform-neutral. The Node version must be one with a published better-sqlite3 prebuild (any current LTS) — otherwise the install falls back to compiling from source and needs a C/C++ toolchain. A Postgres-only deployment avoids this entirely: the driver modules are required lazily, so better-sqlite3 is never loaded when DATABASE_URL is set.
The repo is set up as a single Vercel project at the repo root — vercel.json builds
both workspaces (npm run vercel-build), serves the client's static build
(client/dist) from the CDN, and runs the Express API as one serverless Function
(api/index.js, wrapping server/dist/index.js) behind a /api/* rewrite. The client
and API share one domain, so the default VITE_API_BASE (/api) needs no override and
CORS is same-origin.
vercel link). No framework
preset is needed — vercel.json covers the build/output configuration.DATABASE_URL at your Neon Postgres database — prefer the pooled connection
string, since each Function invocation may be a fresh instance. LINGOFLOW_DB_DRIVER
does not need to be set; Postgres is selected automatically whenever DATABASE_URL is
present.LINGOFLOW_AUTH_SECRET, GOOGLE_OAUTH_CLIENT_ID,
GOOGLE_OAUTH_CLIENT_SECRET, GOOGLE_OAUTH_REDIRECT_URI (the deployed domain's
/api/auth/google/callback — also add this URL to the OAuth client's allowed redirect
URIs in Google Cloud Console), PUBLIC_APP_URL (the deployed domain, used in
verification emails), SMTP_* / EMAIL_FROM, CONTRIBUTION_REVIEWER_EMAILS,
LOG_LEVEL.initSchema() bootstrap the long-running server runs),
not on every request.client/
src/
App.tsx # App shell — owns all top-level state
api.ts # Typed HTTP client
constants.ts # Page paths, defaults
styles.css # All app styles (no CSS-in-JS)
components/
AdminPage.tsx # Admin wrapper page (coverage + future sections)
AuthPage.tsx
BookmarksPage.tsx
ContentStatsPage.tsx # Content coverage charts and grid
ContributePage.tsx
LearnPage.tsx
PracticePage.tsx
SessionPlayer.tsx
SetupPage.tsx
StatsPage.tsx
session/ # Session engine, speech, snapshot hooks
hooks/ # useAppNavigation, useAuthenticatedAppData, etc.
types/ # course.ts, session.ts, contribution.ts
utils/ # theme / path helpers
__tests__/ # Vitest + Testing Library
server/
src/
index.ts # Express setup + route registration + answer evaluation
db.ts # Schema, migrations, all queries (dialect-neutral SQL)
db/
driver.ts # Driver factory + the better-sqlite3-shaped facade db.ts uses
sqliteDriver.ts # better-sqlite3 backend
postgresDriver.ts # node-postgres backend (Neon)
dialect.ts # SQLite -> Postgres SQL translation, ? -> $n placeholders
data.ts # Content + session logic entrypoint
data/
contentLoader.ts # Loads JSON files from content/languages/
sessionGenerator.ts # Question set builder (difficulty, spaced repetition)
constants.ts # Categories, CEFR levels, XP multipliers
routes/
authRoutes.ts
courseRoutes.ts # Course catalog + admin content-stats endpoint
sessionRoutes.ts
userRoutes.ts
auth/
tokenService.ts
password.ts
__tests__/ # Node test runner integration tests
content/
languages/ # Per-language JSON exercise files
english/
spanish/
russian/
italian/
swedish/
french/
german/
practice_words/ # Per-language practice word pools (+ _template.json)
_template.json # Canonical English word list (translation source)
english.json
spanish.json
...
npm run install:all # Install all workspace dependencies
npm run dev # Start server (:4000) + client (:5173) concurrently
npm run translate:language # Interactive LibreTranslate content generator
npm run build # Production client bundle
npm run start # Serve backend + built frontend
npm run lint # ESLint (flat config)
npm run lint:fix # Auto-fix lint issues
npm run format # Prettier
npm run format:check # Verify formatting without writing
npm run test # Full test suite (server + client)
npm run verify # Lint + client tests
| Method | Path | Description |
|---|---|---|
GET | /api/health | Health check |
POST | /api/auth/register | Create account |
POST | /api/auth/login | Sign in |
POST | /api/auth/verify-email | Verify email token |
POST | /api/auth/resend-verification | Resend verification email |
POST | /api/auth/forgot-password | Request password reset link |
POST | /api/auth/reset-password | Apply password reset |
GET | /api/auth/google/start | Begin Google OAuth2 flow |
GET | /api/auth/google/callback | Google OAuth2 callback |
GET | /api/languages | List available course languages |
POST | /api/visitors/login | Track login page visit (telemetry) |
| Method | Path | Description |
|---|---|---|
GET | /api/auth/me | Current user info |
POST | /api/auth/delete-account | Delete account (local auth only) |
GET | /api/course?language=<id> | Course catalog with progress |
GET | /api/content/metrics?language=<id> | Level coverage metrics |
POST | /api/session/start | Start a new session; pass mode: "mistakes" and category: "__mistakes__" for a cross-category mistake review |
POST | /api/session/daily | Start the daily challenge |
POST | /api/session/complete | Submit session results |
GET | /api/settings | Get learner settings |
PUT | /api/settings | Save learner settings |
GET | /api/progress?language=<id> | Learner progress for a language |
GET | /api/progress-overview | Progress summary across all languages |
GET | /api/stats?language=<id> | Stats dashboard data |
GET | /api/bookmarks?language=<id> | List bookmarks |
POST | /api/bookmarks | Add a bookmark |
DELETE | /api/bookmarks/:questionId | Remove a bookmark |
GET | /api/stories?language=<id>&level=<lvl>&category=<cat> | List story summaries (filterable) |
GET | /api/stories/:id | Fetch a full story (sentences, glossary, cultural note) |
POST | /api/stories/:id/complete | Mark a story finished (idempotent, per user) |
GET | /api/saved-words?language=<id> | List words saved from the Story Reader |
POST | /api/saved-words | Save a word to review (idempotent; enters the SRS queue) |
DELETE | /api/saved-words/:word?language=<id> | Remove a saved word |
POST | /api/community/contribute | Submit a community exercise |
GET | /api/community/contributions | List contributions (own or all) |
PATCH | /api/community/contributions/:id | Update moderation status |
GET | /api/dictionary/batch?lang=<id>&words=<w1,w2> | Batch word translations (SQLite-cached, LibreTranslate fallback) |
| Method | Path | Description |
|---|---|---|
GET | /api/visitors/stats | Login page visit aggregate metrics |
GET | /api/admin/content-stats | Exercise counts by language × category × CEFR level |
In non-production builds a Setup toggle appears: "Dev only: unlock all lessons". This bypasses the normal category unlock progression for the signed-in user. The server only honours this flag when NODE_ENV !== "production".
better-sqlite3 is a native module. Use Node LTS (20.x or 22.x). If install fails on Windows: update Node/npm, delete all node_modules folders, and reinstall.:4000.eslint . behaves differently from npm run lint, a global ESLint install may be taking precedence — prefer npx eslint . or the npm script.119 commits
53 commits
TypeScript
85.3%
CSS
8.5%
JavaScript
6.1%
A Language learning app that tracks your progress
TypeScript
2
172 commits
updated Aug 19, 2026
A focused language learning app built with React + Express. Adaptive CEFR progression, persistent learner data, and a variety of exercise types across seven languages.
Hover over any word in a reverse-translation exercise (translating to English) or in a cloze sentence to see a tooltip. Three sources are checked in priority order:
'word' = meaning pattern in the exercise hints array.wordGlossary field on the exercise.word_translations SQLite cache first (populated at startup from the content files), then calls a self-hosted LibreTranslate instance. Each unique word is looked up at most once and cached permanently.At server startup, the word_translations table is wiped and rebuilt from authoritative content sources: wordGlossary fields, "Vocabulary: X" flashcard pairs, and single-word hint patterns. This ensures known exercise vocabulary is always translated correctly without any API call.
Set LIBRETRANSLATE_URL and LIBRETRANSLATE_API_KEY in server/.env to enable the API fallback. Self-hosting with Docker:
# Start a LibreTranslate instance with API key support
docker run -it -p 5000:5000 libretranslate/libretranslate --api-keys
# Generate a key (in a second terminal)
docker exec <container-id> ltmanage keys add
Fetched translations are validated before caching: punctuation-only responses and strings identical to the source word are discarded. Translations are normalized — trailing punctuation stripped, ALL-CAPS lowercased, first character lowercased.
The repo also includes an interactive TypeScript utility for generating new language content from English via LibreTranslate:
npm run translate:language
The tool reads server/.env, lets you select a supported target language and category files, rate-limits API calls if needed, and writes only new files under server/content/languages/<language>/. It never overwrites existing category files.
The wizard first asks what to generate — course categories, the practice word pool, or Story Reader stories. Choosing practice words translates the English word list in server/content/practice_words/_template.json into the target language, writing server/content/practice_words/<language>.json. Translations are batched (TRANSLATE_BATCH_SIZE words per request), so a ~1000-word pool costs roughly 20 API calls rather than one per word.
Choosing Stories reads the hand-authored server/content/stories/english.json, translates each sentence and title English → target, then runs a reverse target → English pass to build a per-word glossary. Each glossary entry borrows its part-of-speech (and an exact-match grammar note) from the English source glossary, and the file is written in the same compact one-line-per-entry layout as the hand-authored stories. Output goes to server/content/stories/<language>.json. As with all jobs it never overwrites an existing file, so delete a language's existing story file first if you want to regenerate it from the longer English source.
To stop MT from carrying English proper nouns straight through (e.g. a Spanish story still set in London), scripts/libretranslate/story-localization.json provides per-culture overrides keyed by source story id. terms swap culturally-specific words — place names and currency — so each language gets a native setting (the B1 letter becomes Barcelona / Paris / Berlin / Rome / Stockholm), and culturalNote replaces the explanatory note with one describing the target culture. The native spelling (e.g. Roma) is fed to MT and kept out of the glossary, while the English exonym (Rome) is shown as the reference; anything omitted from the map falls back to the English source.
The generator code is split for clarity under scripts/libretranslate/: terminal-menu.ts (generic interactive prompts), content-generator.ts (translation + JSON file IO), and index.ts (the wizard that wires them together).
Content authors can add curated per-word translations to any exercise via a wordGlossary object:
{
"id": "ru-gr-a1-s02",
"correctAnswer": "Вчера я занимался два часа.",
"hints": ["'Вчера' = yesterday; leads the sentence naturally."],
"wordGlossary": {
"вчера": "yesterday",
"я": "I",
"занимался": "studied / was studying",
"два": "two",
"часа": "hours (gen. sg.)"
}
}
Keys are lowercased word forms exactly as they appear in correctAnswer; values are short English glosses. The field is optional and validated at server startup.
1–4 select a multiple-choice option, Enter submitsVisible only to the first registered user and anyone listed in CONTRIBUTION_REVIEWER_EMAILS.
| Layer | Technology |
|---|---|
| Frontend | React 19, Vite, TypeScript |
| Backend | Express 5, TypeScript (strip-types) |
| Database | SQLite (better-sqlite3) or PostgreSQL / Neon (pg) |
| Auth | JWT (30-day TTL), bcrypt, Google OAuth2 |
| Charts | Hand-rolled SVG — no chart library |
| Tests | Vitest (client), Node test runner (server) |
LingoFlow runs on either SQLite or PostgreSQL. The driver is selected at startup:
| Condition | Driver |
|---|---|
DATABASE_URL is set | PostgreSQL (pg) |
DATABASE_URL is unset | SQLite (better-sqlite3), at server/data/lingoflow.db |
LINGOFLOW_DB_DRIVER=sqlite|postgres | Overrides the above |
SQLite is the default for local development and is what the test suite always runs against, so no database server is needed to work on the app. Postgres (Neon) is intended for hosted deployments.
There is one copy of every query. server/src/db.ts writes dialect-neutral SQL with
? placeholders; server/src/db/dialect.ts translates it for Postgres (identity
columns, timestamp defaults, ? → $1..$n). Schema creation and migrations run once
at startup through initSchema() and are idempotent on both backends.
Put the pooled connection string in server/.env:
DATABASE_URL=postgresql://user:pass@ep-xxx-pooler.region.aws.neon.tech/db?sslmode=require
Start the server once and it creates the schema.
npm run db:import-sqlite # from server/data/lingoflow.db
npm run db:import-sqlite -- --from ./backup.db # from somewhere else
npm run db:import-sqlite -- --dry-run # report row counts only
The import copies all tables in FK-safe order, resyncs identity sequences afterwards (so the next insert does not collide on a primary key), and is safe to re-run — existing rows are reported as skipped.
scripts/repair-xp.tsandscripts/set-xp.tsopen the SQLite file directly and are SQLite-only maintenance tools; they do not work against Postgres.
npm install
npm run dev
http://localhost:5173http://localhost:4000/apiCreate server/.env (copy server/.env.example) for local development.
| Variable | Side | Description |
|---|---|---|
LINGOFLOW_AUTH_SECRET | server | JWT signing secret — change in production |
GOOGLE_OAUTH_CLIENT_ID | server | Google OAuth2 web client ID |
GOOGLE_OAUTH_CLIENT_SECRET | server | Google OAuth2 web client secret |
GOOGLE_OAUTH_REDIRECT_URI | server | Callback URL registered in Google console (default: http://localhost:4000/api/auth/google/callback) |
PUBLIC_APP_URL | server | Base URL used in verification/reset emails (e.g. https://app.example.com) |
RESEND_API_KEY | server | Preferred email delivery — Resend HTTPS API. Takes priority over SMTP when set |
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_SECURE, EMAIL_FROM | server | SMTP fallback for transactional emails, used when RESEND_API_KEY is unset. EMAIL_FROM is used for both delivery paths |
CONTRIBUTION_REVIEWER_EMAILS | server | Comma-separated moderator email list |
LOG_LEVEL | server | debug | info | warn | error (default: info) |
VITE_API_BASE | client | API base path/URL (default: /api) |
Admin access: the first registered user (id = 1) is automatically an admin. Additional admins are granted via CONTRIBUTION_REVIEWER_EMAILS.
npm run build # bundles client into server/dist/client
npm run start # serves everything from http://localhost:4000
Or build the server bundle separately:
npm run build --prefix server # typecheck + minified bundle → server/dist/index.js
npm run start:dist --prefix server
Do not run npm update on the server — that resolves to newer versions and rewrites the lockfile, which is non-reproducible. Update dependencies locally, commit the single root lockfile (npm workspaces — server/ and client/ share it, there are no per-workspace lockfiles), then install from it on the server with npm ci (a clean, reproducible install that fails if package.json and the lockfile disagree).
git pull
npm ci
npm run build # bundles client into server/dist/client
# then restart the service (pm2 / systemd / etc.)
If you build the artifacts in CI and ship dist/ to the server, the runtime box only needs production deps:
npm ci --omit=dev
Otherwise keep dev dependencies — the build tools (vite, esbuild, tsc) live under devDependencies.
Native modules: never copy node_modules from another machine. better-sqlite3 is a native module whose binary is platform- and ABI-specific; npm ci fetches the correct prebuilt binary for the server's OS and Node version. The lockfile itself is platform-neutral. The Node version must be one with a published better-sqlite3 prebuild (any current LTS) — otherwise the install falls back to compiling from source and needs a C/C++ toolchain. A Postgres-only deployment avoids this entirely: the driver modules are required lazily, so better-sqlite3 is never loaded when DATABASE_URL is set.
The repo is set up as a single Vercel project at the repo root — vercel.json builds
both workspaces (npm run vercel-build), serves the client's static build
(client/dist) from the CDN, and runs the Express API as one serverless Function
(api/index.js, wrapping server/dist/index.js) behind a /api/* rewrite. The client
and API share one domain, so the default VITE_API_BASE (/api) needs no override and
CORS is same-origin.
vercel link). No framework
preset is needed — vercel.json covers the build/output configuration.DATABASE_URL at your Neon Postgres database — prefer the pooled connection
string, since each Function invocation may be a fresh instance. LINGOFLOW_DB_DRIVER
does not need to be set; Postgres is selected automatically whenever DATABASE_URL is
present.LINGOFLOW_AUTH_SECRET, GOOGLE_OAUTH_CLIENT_ID,
GOOGLE_OAUTH_CLIENT_SECRET, GOOGLE_OAUTH_REDIRECT_URI (the deployed domain's
/api/auth/google/callback — also add this URL to the OAuth client's allowed redirect
URIs in Google Cloud Console), PUBLIC_APP_URL (the deployed domain, used in
verification emails), SMTP_* / EMAIL_FROM, CONTRIBUTION_REVIEWER_EMAILS,
LOG_LEVEL.initSchema() bootstrap the long-running server runs),
not on every request.client/
src/
App.tsx # App shell — owns all top-level state
api.ts # Typed HTTP client
constants.ts # Page paths, defaults
styles.css # All app styles (no CSS-in-JS)
components/
AdminPage.tsx # Admin wrapper page (coverage + future sections)
AuthPage.tsx
BookmarksPage.tsx
ContentStatsPage.tsx # Content coverage charts and grid
ContributePage.tsx
LearnPage.tsx
PracticePage.tsx
SessionPlayer.tsx
SetupPage.tsx
StatsPage.tsx
session/ # Session engine, speech, snapshot hooks
hooks/ # useAppNavigation, useAuthenticatedAppData, etc.
types/ # course.ts, session.ts, contribution.ts
utils/ # theme / path helpers
__tests__/ # Vitest + Testing Library
server/
src/
index.ts # Express setup + route registration + answer evaluation
db.ts # Schema, migrations, all queries (dialect-neutral SQL)
db/
driver.ts # Driver factory + the better-sqlite3-shaped facade db.ts uses
sqliteDriver.ts # better-sqlite3 backend
postgresDriver.ts # node-postgres backend (Neon)
dialect.ts # SQLite -> Postgres SQL translation, ? -> $n placeholders
data.ts # Content + session logic entrypoint
data/
contentLoader.ts # Loads JSON files from content/languages/
sessionGenerator.ts # Question set builder (difficulty, spaced repetition)
constants.ts # Categories, CEFR levels, XP multipliers
routes/
authRoutes.ts
courseRoutes.ts # Course catalog + admin content-stats endpoint
sessionRoutes.ts
userRoutes.ts
auth/
tokenService.ts
password.ts
__tests__/ # Node test runner integration tests
content/
languages/ # Per-language JSON exercise files
english/
spanish/
russian/
italian/
swedish/
french/
german/
practice_words/ # Per-language practice word pools (+ _template.json)
_template.json # Canonical English word list (translation source)
english.json
spanish.json
...
npm run install:all # Install all workspace dependencies
npm run dev # Start server (:4000) + client (:5173) concurrently
npm run translate:language # Interactive LibreTranslate content generator
npm run build # Production client bundle
npm run start # Serve backend + built frontend
npm run lint # ESLint (flat config)
npm run lint:fix # Auto-fix lint issues
npm run format # Prettier
npm run format:check # Verify formatting without writing
npm run test # Full test suite (server + client)
npm run verify # Lint + client tests
| Method | Path | Description |
|---|---|---|
GET | /api/health | Health check |
POST | /api/auth/register | Create account |
POST | /api/auth/login | Sign in |
POST | /api/auth/verify-email | Verify email token |
POST | /api/auth/resend-verification | Resend verification email |
POST | /api/auth/forgot-password | Request password reset link |
POST | /api/auth/reset-password | Apply password reset |
GET | /api/auth/google/start | Begin Google OAuth2 flow |
GET | /api/auth/google/callback | Google OAuth2 callback |
GET | /api/languages | List available course languages |
POST | /api/visitors/login | Track login page visit (telemetry) |
| Method | Path | Description |
|---|---|---|
GET | /api/auth/me | Current user info |
POST | /api/auth/delete-account | Delete account (local auth only) |
GET | /api/course?language=<id> | Course catalog with progress |
GET | /api/content/metrics?language=<id> | Level coverage metrics |
POST | /api/session/start | Start a new session; pass mode: "mistakes" and category: "__mistakes__" for a cross-category mistake review |
POST | /api/session/daily | Start the daily challenge |
POST | /api/session/complete | Submit session results |
GET | /api/settings | Get learner settings |
PUT | /api/settings | Save learner settings |
GET | /api/progress?language=<id> | Learner progress for a language |
GET | /api/progress-overview | Progress summary across all languages |
GET | /api/stats?language=<id> | Stats dashboard data |
GET | /api/bookmarks?language=<id> | List bookmarks |
POST | /api/bookmarks | Add a bookmark |
DELETE | /api/bookmarks/:questionId | Remove a bookmark |
GET | /api/stories?language=<id>&level=<lvl>&category=<cat> | List story summaries (filterable) |
GET | /api/stories/:id | Fetch a full story (sentences, glossary, cultural note) |
POST | /api/stories/:id/complete | Mark a story finished (idempotent, per user) |
GET | /api/saved-words?language=<id> | List words saved from the Story Reader |
POST | /api/saved-words | Save a word to review (idempotent; enters the SRS queue) |
DELETE | /api/saved-words/:word?language=<id> | Remove a saved word |
POST | /api/community/contribute | Submit a community exercise |
GET | /api/community/contributions | List contributions (own or all) |
PATCH | /api/community/contributions/:id | Update moderation status |
GET | /api/dictionary/batch?lang=<id>&words=<w1,w2> | Batch word translations (SQLite-cached, LibreTranslate fallback) |
| Method | Path | Description |
|---|---|---|
GET | /api/visitors/stats | Login page visit aggregate metrics |
GET | /api/admin/content-stats | Exercise counts by language × category × CEFR level |
In non-production builds a Setup toggle appears: "Dev only: unlock all lessons". This bypasses the normal category unlock progression for the signed-in user. The server only honours this flag when NODE_ENV !== "production".
better-sqlite3 is a native module. Use Node LTS (20.x or 22.x). If install fails on Windows: update Node/npm, delete all node_modules folders, and reinstall.:4000.eslint . behaves differently from npm run lint, a global ESLint install may be taking precedence — prefer npx eslint . or the npm script.119 commits
53 commits
TypeScript
85.3%
CSS
8.5%
JavaScript
6.1%