diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..533a6cb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm typecheck + + - run: pnpm test + + - run: pnpm validate-content + + - run: pnpm check-i18n + + - run: pnpm build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1637f4f --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules +dist +dist-ssr +*.local +.DS_Store +coverage +.turbo +*.log +.env +.env.* +!.env.example diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..47174e4 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100 +} diff --git a/README.md b/README.md index 58a1dbc..f5a89d7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,55 @@ -# coderkeys -An open-source, local-first typing tutor tailored for programmers and technical writers to master code syntax and specific vocabularies. +# CoderKeys + +Open-source, local-first typing tutor tailored for **programmers** and **technical writers** to master code syntax and real-world vocabularies. + +## Features + +- **Skill-based tracks** — Programmers (code syntax, symbols) and Technical Writers (requirements, documentation) +- **Bilingual** — Interface and lessons in `en-US` and `pt-BR` +- **100% local** — No backend, no cloud costs; progress saved in IndexedDB +- **Contributor-friendly** — Add lessons by creating JSON files in `content/` + +## Quick Start + +```bash +git clone https://github.com/iagommendes/coderkeys.git +cd coderkeys +pnpm install +pnpm dev +``` + +Open [http://localhost:5173](http://localhost:5173) in your browser. + +### Other commands + +```bash +pnpm build # Production build +pnpm preview # Preview production build locally +pnpm test # Run all tests +pnpm validate-content # Validate lesson JSON files +pnpm check-i18n # Verify translation key parity +``` + +## Project Structure + +``` +coderkeys/ +├── apps/web/ # React SPA (Vite + Tailwind) +├── packages/engine/ # Typing engine (WPM, accuracy) — framework-agnostic +├── packages/schemas/ # Zod schemas for lessons and progress +├── content/ # Lesson content (contributors edit here) +└── docs/ # Architecture and planning docs +``` + +## Contributing Lessons + +1. Create a JSON file in `content/tracks/{track}/{locale}/{module}/` +2. Follow the schema in `packages/schemas/src/lesson.schema.ts` +3. Run `pnpm validate-content` +4. Open a Pull Request + +See [docs/PLANEJAMENTO-ARQUITETURA.md](docs/PLANEJAMENTO-ARQUITETURA.md) for the full architecture plan. + +## License + +MIT diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/web/.oxlintrc.json b/apps/web/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/apps/web/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..483cd24 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,16 @@ + + + + + + + CoderKeys — Typing Tutor + + + + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..eef1fc5 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,39 @@ +{ + "name": "@coderkeys/web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc -b --noEmit", + "test": "vitest run --passWithNoTests", + "lint": "eslint src" + }, + "dependencies": { + "@coderkeys/engine": "workspace:*", + "@coderkeys/schemas": "workspace:*", + "clsx": "^2.1.1", + "dexie": "^4.0.11", + "i18next": "^24.2.3", + "i18next-browser-languagedetector": "^8.0.4", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-i18next": "^15.4.1", + "react-router-dom": "^7.4.0", + "tailwind-merge": "^3.0.2", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.14", + "@types/node": "^22.14.0", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "tailwindcss": "^4.0.14", + "typescript": "^5.8.2", + "vite": "^8.1.0", + "vitest": "^3.0.9" + } +} diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/icons.svg b/apps/web/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/apps/web/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/app/providers.tsx b/apps/web/src/app/providers.tsx new file mode 100644 index 0000000..bc19427 --- /dev/null +++ b/apps/web/src/app/providers.tsx @@ -0,0 +1,29 @@ +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSettingsStore } from '@/shared/stores/settings.store'; +import '@/i18n'; + +export function AppProviders({ children }: { children: React.ReactNode }) { + const { i18n } = useTranslation(); + const { load, uiLocale, loaded } = useSettingsStore(); + + useEffect(() => { + void load(); + }, [load]); + + useEffect(() => { + if (loaded) { + void i18n.changeLanguage(uiLocale); + } + }, [loaded, uiLocale, i18n]); + + if (!loaded) { + return ( +
+ Loading... +
+ ); + } + + return <>{children}; +} diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx new file mode 100644 index 0000000..21e7d84 --- /dev/null +++ b/apps/web/src/app/router.tsx @@ -0,0 +1,27 @@ +import { BrowserRouter, Navigate, Route, Routes, useParams } from 'react-router-dom'; +import { Layout } from '@/shared/components/Layout'; +import { CatalogPage, TrackPage } from '@/features/catalog/pages/CatalogPage'; +import { LessonPage } from '@/features/lesson/pages/LessonPage'; +import { SettingsPage } from '@/features/settings/pages/SettingsPage'; + +function TrackRoute() { + const { trackId } = useParams<{ trackId: string }>(); + if (!trackId) return ; + return ; +} + +export function AppRouter() { + return ( + + + + } /> + } /> + } /> + } /> + } /> + + + + ); +} diff --git a/apps/web/src/assets/hero.png b/apps/web/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/apps/web/src/assets/hero.png differ diff --git a/apps/web/src/assets/react.svg b/apps/web/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/apps/web/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/assets/vite.svg b/apps/web/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/apps/web/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/apps/web/src/db/database.ts b/apps/web/src/db/database.ts new file mode 100644 index 0000000..cb63d64 --- /dev/null +++ b/apps/web/src/db/database.ts @@ -0,0 +1,63 @@ +import Dexie, { type EntityTable } from 'dexie'; +import type { Locale } from '@coderkeys/schemas'; + +export interface UserSettings { + id: 'default'; + uiLocale: Locale; + contentLocale: Locale; + theme: 'light' | 'dark' | 'system'; + strictMode: boolean; + soundEnabled: boolean; +} + +export interface LessonProgress { + lessonId: string; + track: string; + module: string; + bestWpm: number; + bestAccuracy: number; + bestAwpm: number; + attempts: number; + completed: boolean; + lastPlayedAt: string; +} + +export interface SessionRecord { + id: string; + lessonId: string; + wpm: number; + rawWpm: number; + accuracy: number; + awpm: number; + durationMs: number; + errorPositions: number[]; + errorChars: string[]; + completedAt: string; +} + +export class CoderKeysDatabase extends Dexie { + settings!: EntityTable; + lessonProgress!: EntityTable; + sessions!: EntityTable; + + constructor() { + super('coderkeys'); + + this.version(1).stores({ + settings: 'id', + lessonProgress: 'lessonId, track, module, lastPlayedAt', + sessions: 'id, lessonId, completedAt', + }); + } +} + +export const db = new CoderKeysDatabase(); + +export const DEFAULT_SETTINGS: UserSettings = { + id: 'default', + uiLocale: 'en-US', + contentLocale: 'en-US', + theme: 'dark', + strictMode: false, + soundEnabled: false, +}; diff --git a/apps/web/src/db/repositories/progress.repo.ts b/apps/web/src/db/repositories/progress.repo.ts new file mode 100644 index 0000000..10b2d72 --- /dev/null +++ b/apps/web/src/db/repositories/progress.repo.ts @@ -0,0 +1,52 @@ +import type { SessionResult as EngineResult } from '@coderkeys/engine'; +import { db, type LessonProgress, type SessionRecord } from '../database'; + +export async function getLessonProgress(lessonId: string): Promise { + return db.lessonProgress.get(lessonId); +} + +export async function getAllLessonProgress(): Promise { + return db.lessonProgress.toArray(); +} + +export async function saveSessionResult( + lessonId: string, + track: string, + module: string, + result: EngineResult, +): Promise { + const sessionId = crypto.randomUUID(); + const completedAt = new Date().toISOString(); + + const session: SessionRecord = { + id: sessionId, + lessonId, + wpm: result.wpm, + rawWpm: result.rawWpm, + accuracy: result.accuracy, + awpm: result.awpm, + durationMs: result.durationMs, + errorPositions: result.errorPositions, + errorChars: result.errorChars, + completedAt, + }; + + const existing = await db.lessonProgress.get(lessonId); + + const progress: LessonProgress = { + lessonId, + track, + module, + bestWpm: Math.max(existing?.bestWpm ?? 0, result.wpm), + bestAccuracy: Math.max(existing?.bestAccuracy ?? 0, result.accuracy), + bestAwpm: Math.max(existing?.bestAwpm ?? 0, result.awpm), + attempts: (existing?.attempts ?? 0) + 1, + completed: result.completed || (existing?.completed ?? false), + lastPlayedAt: completedAt, + }; + + await db.transaction('rw', db.sessions, db.lessonProgress, async () => { + await db.sessions.put(session); + await db.lessonProgress.put(progress); + }); +} diff --git a/apps/web/src/db/repositories/settings.repo.ts b/apps/web/src/db/repositories/settings.repo.ts new file mode 100644 index 0000000..d7a6c01 --- /dev/null +++ b/apps/web/src/db/repositories/settings.repo.ts @@ -0,0 +1,18 @@ +import type { Locale } from '@coderkeys/schemas'; +import { db, DEFAULT_SETTINGS, type UserSettings } from '../database'; + +export async function getSettings(): Promise { + const settings = await db.settings.get('default'); + return settings ?? DEFAULT_SETTINGS; +} + +export async function saveSettings(partial: Partial>): Promise { + const current = await getSettings(); + const updated: UserSettings = { ...current, ...partial }; + await db.settings.put(updated); + return updated; +} + +export async function setUiLocale(locale: Locale): Promise { + return saveSettings({ uiLocale: locale }); +} diff --git a/apps/web/src/features/catalog/lesson-loader.ts b/apps/web/src/features/catalog/lesson-loader.ts new file mode 100644 index 0000000..fb54d95 --- /dev/null +++ b/apps/web/src/features/catalog/lesson-loader.ts @@ -0,0 +1,84 @@ +import manifest from '@content/manifest.json'; +import { LessonSchema, ManifestSchema, TrackMetaSchema, type Lesson, type Locale, type Manifest } from '@coderkeys/schemas'; + +const lessonModules = import.meta.glob<{ default: Lesson }>('@content/tracks/**/*.json', { + eager: true, +}); + +const trackMetaModules = import.meta.glob<{ default: Record & { id: string } }>( + '@content/tracks/*/_meta.json', + { eager: true }, +); + +export interface LessonRef { + lessonId: string; + trackId: string; + moduleId: string; + locale: Locale; + fileSlug: string; +} + +export interface TrackMetaView { + id: string; + name: Record; + description: Record; + defaultModule: string; +} + +function parseLessonPath(path: string): LessonRef | null { + const match = path.match(/tracks\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)\.json$/); + if (!match) return null; + + const [, trackId, locale, moduleId, fileSlug] = match; + if (locale !== 'en-US' && locale !== 'pt-BR') return null; + + return { + lessonId: `${trackId}/${moduleId}/${locale}/${fileSlug}`, + trackId, + moduleId, + locale, + fileSlug, + }; +} + +export function getManifest(): Manifest { + return ManifestSchema.parse(manifest); +} + +export function getTrackMeta(trackId: string): TrackMetaView | null { + const entry = Object.entries(trackMetaModules).find(([path]) => path.includes(`/tracks/${trackId}/`)); + if (!entry) return null; + return TrackMetaSchema.parse(entry[1].default) as TrackMetaView; +} + +export function getAllLessons(): Lesson[] { + return Object.entries(lessonModules) + .filter(([path]) => !path.includes('_meta.json')) + .map(([, mod]) => LessonSchema.parse(mod.default)); +} + +export function getLessonById(lessonId: string): Lesson | null { + const entry = Object.entries(lessonModules) + .filter(([path]) => !path.includes('_meta.json')) + .find(([path]) => { + const ref = parseLessonPath(path); + return ref?.lessonId === lessonId; + }); + + if (!entry) return null; + return LessonSchema.parse(entry[1].default); +} + +export function getLessonsForTrack(trackId: string, locale: Locale): Lesson[] { + return getAllLessons().filter((l) => l.track === trackId && l.locale === locale); +} + +export function getLessonsForModule(trackId: string, moduleId: string, locale: Locale): Lesson[] { + return getAllLessons().filter( + (l) => l.track === trackId && l.module === moduleId && l.locale === locale, + ); +} + +export function buildLessonId(trackId: string, moduleId: string, locale: Locale, fileSlug: string): string { + return `${trackId}/${moduleId}/${locale}/${fileSlug}`; +} diff --git a/apps/web/src/features/catalog/pages/CatalogPage.tsx b/apps/web/src/features/catalog/pages/CatalogPage.tsx new file mode 100644 index 0000000..d3d8c07 --- /dev/null +++ b/apps/web/src/features/catalog/pages/CatalogPage.tsx @@ -0,0 +1,118 @@ +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useEffect, useState } from 'react'; +import type { LessonProgress } from '@/db/database'; +import { getAllLessonProgress } from '@/db/repositories/progress.repo'; +import { getManifest, getTrackMeta } from '@/features/catalog/lesson-loader'; +import { useSettingsStore } from '@/shared/stores/settings.store'; +import { Card } from '@/shared/components/ui'; + +export function CatalogPage() { + const { t } = useTranslation('catalog'); + const { t: tc } = useTranslation('common'); + const contentLocale = useSettingsStore((s) => s.contentLocale); + const manifest = getManifest(); + + return ( +
+
+

{t('title')}

+

{t('subtitle')}

+
+ +
+ {manifest.tracks.map((track) => { + const meta = getTrackMeta(track.id); + if (!meta) return null; + + return ( + + +
+ {track.icon === 'code' ? '💻' : '📝'} +
+

{meta.name[contentLocale]}

+

{meta.description[contentLocale]}

+

+ {track.modules.length} {t('modules').toLowerCase()} +

+
+
+
+ + ); + })} +
+ +

+ {tc('tagline')} +

+
+ ); +} + +export function TrackPage({ trackId }: { trackId: string }) { + const { t } = useTranslation('catalog'); + const contentLocale = useSettingsStore((s) => s.contentLocale); + const manifest = getManifest(); + const meta = getTrackMeta(trackId); + const track = manifest.tracks.find((tr) => tr.id === trackId); + const [progress, setProgress] = useState([]); + + useEffect(() => { + void getAllLessonProgress().then(setProgress); + }, []); + + if (!track || !meta) { + return

Track not found.

; + } + + return ( +
+
+

{meta.name[contentLocale]}

+

{meta.description[contentLocale]}

+
+ + {track.modules.map((mod) => { + const lessonSlugs = mod.lessons[contentLocale] ?? []; + + return ( +
+

{mod.id.replace(/-/g, ' ')}

+ + {lessonSlugs.length === 0 ? ( +

{t('noLessons')}

+ ) : ( +
+ {lessonSlugs.map((slug) => { + const lessonId = `${trackId}/${mod.id}/${contentLocale}/${slug}`; + const lp = progress.find((p) => p.lessonId === lessonId); + + return ( + + +
+

{slug.replace(/^\d{3}-/, '').replace(/-/g, ' ')}

+

+ {lp?.completed ? t('completed') : lp ? `${t('attempts')}: ${lp.attempts}` : t('notStarted')} +

+
+ {lp && ( +
+

{t('bestWpm')}

+

{lp.bestWpm}

+
+ )} +
+ + ); + })} +
+ )} +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/features/lesson/components/CharDisplay.tsx b/apps/web/src/features/lesson/components/CharDisplay.tsx new file mode 100644 index 0000000..a14d60b --- /dev/null +++ b/apps/web/src/features/lesson/components/CharDisplay.tsx @@ -0,0 +1,37 @@ +import { buildCharStates } from '@coderkeys/engine'; +import { cn } from '@/shared/lib/cn'; + +interface CharDisplayProps { + passage: string; + input: string; + display: 'code' | 'prose'; +} + +export function CharDisplay({ passage, input, display }: CharDisplayProps) { + const states = buildCharStates(passage, input); + + return ( +
+ {states.map((state, index) => ( + + {state.char === '\n' ? '\n' : state.char} + + ))} +
+ ); +} diff --git a/apps/web/src/features/lesson/components/LessonSession.tsx b/apps/web/src/features/lesson/components/LessonSession.tsx new file mode 100644 index 0000000..0def6c6 --- /dev/null +++ b/apps/web/src/features/lesson/components/LessonSession.tsx @@ -0,0 +1,154 @@ +import { useEffect, useReducer, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { TypingSession } from '@coderkeys/engine'; +import type { Lesson } from '@coderkeys/schemas'; +import { saveSessionResult } from '@/db/repositories/progress.repo'; +import { useSettingsStore } from '@/shared/stores/settings.store'; +import { CharDisplay } from './CharDisplay'; +import { Button, Card } from '@/shared/components/ui'; +import { buildLessonId } from '@/features/catalog/lesson-loader'; + +interface LessonSessionProps { + lesson: Lesson; + onComplete: () => void; +} + +function formatTime(ms: number): string { + const seconds = Math.floor(ms / 1000); + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; +} + +export function LessonSession({ lesson, onComplete }: LessonSessionProps) { + const { t } = useTranslation('lesson'); + const strictMode = useSettingsStore((s) => s.strictMode); + const inputRef = useRef(null); + const sessionRef = useRef(null); + const savedRef = useRef(false); + const [, forceRender] = useReducer((x: number) => x + 1, 0); + + useEffect(() => { + sessionRef.current = new TypingSession({ + passage: lesson.content.text, + strictMode, + }); + sessionRef.current.prepare(); + inputRef.current?.focus(); + savedRef.current = false; + }, [lesson, strictMode]); + + const session = sessionRef.current; + const state = session?.getState(); + const metrics = session?.getLiveMetrics(); + const isComplete = state?.status === 'complete'; + + useEffect(() => { + if (!isComplete || !session || savedRef.current) return; + + savedRef.current = true; + const result = session.getResult(); + const lessonId = buildLessonId(lesson.track, lesson.module, lesson.locale, lesson.id); + + void saveSessionResult(lessonId, lesson.track, lesson.module, result); + }, [isComplete, lesson, session]); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (!session) return; + if (event.ctrlKey || event.metaKey || event.altKey) return; + if (event.key === 'Tab') return; + + event.preventDefault(); + + if (state?.status === 'ready') { + session.start(); + } + + const handled = session.handleKey(event.key); + if (handled) { + forceRender(); + } + }; + + const goalMet = + lesson.goals && + metrics && + (!lesson.goals.minWpm || metrics.wpm >= lesson.goals.minWpm) && + (!lesson.goals.minAccuracy || metrics.accuracy >= lesson.goals.minAccuracy); + + if (isComplete && session) { + const result = session.getResult(); + return ( + +
+

{t('completeTitle')}

+

{t('completeSubtitle')}

+
+ +
+ + + + +
+ +

+ {goalMet ? t('goalMet') : t('goalMissed')} +

+ +
+ + +
+
+ ); + } + + return ( +
+
+ + + +
+ + {strictMode &&

{t('strictModeHint')}

} + +
inputRef.current?.focus()} + role="presentation" + > + + {state?.status === 'ready' && ( +

{t('pressToStart')}

+ )} +
+ + {}} + /> +
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( + +

{label}

+

{value}

+
+ ); +} diff --git a/apps/web/src/features/lesson/pages/LessonPage.tsx b/apps/web/src/features/lesson/pages/LessonPage.tsx new file mode 100644 index 0000000..361c354 --- /dev/null +++ b/apps/web/src/features/lesson/pages/LessonPage.tsx @@ -0,0 +1,36 @@ +import { Link, useNavigate, useParams } from 'react-router-dom'; +import { getLessonById } from '@/features/catalog/lesson-loader'; +import { LessonSession } from '../components/LessonSession'; +import { Button } from '@/shared/components/ui'; + +export function LessonPage() { + const { lessonId: encodedId } = useParams<{ lessonId: string }>(); + const navigate = useNavigate(); + const lessonId = encodedId ? decodeURIComponent(encodedId) : ''; + const lesson = getLessonById(lessonId); + + if (!lesson) { + return ( +
+

Lesson not found.

+ + + +
+ ); + } + + return ( +
+
+ + ← {lesson.track} + +

{lesson.title}

+

{lesson.description}

+
+ + navigate(`/tracks/${lesson.track}`)} /> +
+ ); +} diff --git a/apps/web/src/features/settings/pages/SettingsPage.tsx b/apps/web/src/features/settings/pages/SettingsPage.tsx new file mode 100644 index 0000000..d2093e6 --- /dev/null +++ b/apps/web/src/features/settings/pages/SettingsPage.tsx @@ -0,0 +1,100 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { Locale } from '@coderkeys/schemas'; +import { useSettingsStore } from '@/shared/stores/settings.store'; +import { Button, Card } from '@/shared/components/ui'; + +export function SettingsPage() { + const { t, i18n } = useTranslation('settings'); + const { uiLocale, contentLocale, strictMode, loaded, load, setUiLocale, setContentLocale, setStrictMode } = + useSettingsStore(); + const [saved, setSaved] = useState(false); + + useEffect(() => { + void load(); + }, [load]); + + useEffect(() => { + if (loaded) { + void i18n.changeLanguage(uiLocale); + } + }, [loaded, uiLocale, i18n]); + + const handleUiLocale = async (locale: Locale) => { + await setUiLocale(locale); + await i18n.changeLanguage(locale); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + }; + + if (!loaded) return null; + + return ( +
+

{t('title')}

+ + +
+ +
+ handleUiLocale('en-US')}> + English + + handleUiLocale('pt-BR')}> + Português (BR) + +
+
+ +
+ +
+ void setContentLocale('en-US')} + > + English + + void setContentLocale('pt-BR')} + > + Português (BR) + +
+
+ +
+
+

{t('strictMode')}

+

{t('strictModeDesc')}

+
+ void setStrictMode(e.target.checked)} + className="h-4 w-4 accent-accent" + /> +
+ + {saved &&

{t('saved')}

} +
+
+ ); +} + +function LocaleButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts new file mode 100644 index 0000000..b88b8a5 --- /dev/null +++ b/apps/web/src/i18n/index.ts @@ -0,0 +1,45 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +import enCommon from './locales/en-US/common.json'; +import enCatalog from './locales/en-US/catalog.json'; +import enLesson from './locales/en-US/lesson.json'; +import enSettings from './locales/en-US/settings.json'; + +import ptCommon from './locales/pt-BR/common.json'; +import ptCatalog from './locales/pt-BR/catalog.json'; +import ptLesson from './locales/pt-BR/lesson.json'; +import ptSettings from './locales/pt-BR/settings.json'; + +void i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources: { + 'en-US': { + common: enCommon, + catalog: enCatalog, + lesson: enLesson, + settings: enSettings, + }, + 'pt-BR': { + common: ptCommon, + catalog: ptCatalog, + lesson: ptLesson, + settings: ptSettings, + }, + }, + fallbackLng: 'en-US', + supportedLngs: ['en-US', 'pt-BR'], + defaultNS: 'common', + ns: ['common', 'catalog', 'lesson', 'settings'], + interpolation: { escapeValue: false }, + detection: { + order: ['localStorage', 'navigator'], + caches: ['localStorage'], + lookupLocalStorage: 'coderkeys-ui-locale', + }, + }); + +export default i18n; diff --git a/apps/web/src/i18n/locales/en-US/catalog.json b/apps/web/src/i18n/locales/en-US/catalog.json new file mode 100644 index 0000000..998466d --- /dev/null +++ b/apps/web/src/i18n/locales/en-US/catalog.json @@ -0,0 +1,11 @@ +{ + "title": "Lesson Catalog", + "subtitle": "Choose a track and module to start practicing", + "modules": "Modules", + "lessons": "Lessons", + "noLessons": "No lessons available for this language yet.", + "bestWpm": "Best WPM", + "attempts": "Attempts", + "completed": "Completed", + "notStarted": "Not started" +} diff --git a/apps/web/src/i18n/locales/en-US/common.json b/apps/web/src/i18n/locales/en-US/common.json new file mode 100644 index 0000000..8ba6382 --- /dev/null +++ b/apps/web/src/i18n/locales/en-US/common.json @@ -0,0 +1,17 @@ +{ + "appName": "CoderKeys", + "tagline": "Typing skills for programmers and technical writers", + "nav": { + "catalog": "Catalog", + "settings": "Settings", + "github": "GitHub" + }, + "actions": { + "start": "Start", + "retry": "Try again", + "back": "Back", + "save": "Save" + }, + "difficulty": "Difficulty", + "minutes": "min" +} diff --git a/apps/web/src/i18n/locales/en-US/lesson.json b/apps/web/src/i18n/locales/en-US/lesson.json new file mode 100644 index 0000000..2a00b42 --- /dev/null +++ b/apps/web/src/i18n/locales/en-US/lesson.json @@ -0,0 +1,12 @@ +{ + "liveWpm": "WPM", + "liveAccuracy": "Accuracy", + "liveAwpm": "AWPM", + "elapsed": "Time", + "completeTitle": "Lesson complete!", + "completeSubtitle": "Great job. Your results have been saved locally.", + "goalMet": "Goal met", + "goalMissed": "Keep practicing", + "pressToStart": "Click here or press any key to start", + "strictModeHint": "Strict mode: errors block progress" +} diff --git a/apps/web/src/i18n/locales/en-US/settings.json b/apps/web/src/i18n/locales/en-US/settings.json new file mode 100644 index 0000000..ca96215 --- /dev/null +++ b/apps/web/src/i18n/locales/en-US/settings.json @@ -0,0 +1,9 @@ +{ + "title": "Settings", + "uiLocale": "Interface language", + "contentLocale": "Lesson content language", + "strictMode": "Strict mode", + "strictModeDesc": "Block typing when a character is incorrect", + "theme": "Theme", + "saved": "Settings saved" +} diff --git a/apps/web/src/i18n/locales/pt-BR/catalog.json b/apps/web/src/i18n/locales/pt-BR/catalog.json new file mode 100644 index 0000000..3878d03 --- /dev/null +++ b/apps/web/src/i18n/locales/pt-BR/catalog.json @@ -0,0 +1,11 @@ +{ + "title": "Catálogo de Lições", + "subtitle": "Escolha uma trilha e módulo para começar a praticar", + "modules": "Módulos", + "lessons": "Lições", + "noLessons": "Nenhuma lição disponível para este idioma ainda.", + "bestWpm": "Melhor WPM", + "attempts": "Tentativas", + "completed": "Concluída", + "notStarted": "Não iniciada" +} diff --git a/apps/web/src/i18n/locales/pt-BR/common.json b/apps/web/src/i18n/locales/pt-BR/common.json new file mode 100644 index 0000000..0e83180 --- /dev/null +++ b/apps/web/src/i18n/locales/pt-BR/common.json @@ -0,0 +1,17 @@ +{ + "appName": "CoderKeys", + "tagline": "Habilidades de digitação para programadores e escritores técnicos", + "nav": { + "catalog": "Catálogo", + "settings": "Configurações", + "github": "GitHub" + }, + "actions": { + "start": "Iniciar", + "retry": "Tentar novamente", + "back": "Voltar", + "save": "Salvar" + }, + "difficulty": "Dificuldade", + "minutes": "min" +} diff --git a/apps/web/src/i18n/locales/pt-BR/lesson.json b/apps/web/src/i18n/locales/pt-BR/lesson.json new file mode 100644 index 0000000..af1cd9e --- /dev/null +++ b/apps/web/src/i18n/locales/pt-BR/lesson.json @@ -0,0 +1,12 @@ +{ + "liveWpm": "PPM", + "liveAccuracy": "Precisão", + "liveAwpm": "PPM ajustado", + "elapsed": "Tempo", + "completeTitle": "Lição concluída!", + "completeSubtitle": "Ótimo trabalho. Seus resultados foram salvos localmente.", + "goalMet": "Meta atingida", + "goalMissed": "Continue praticando", + "pressToStart": "Clique aqui ou pressione qualquer tecla para começar", + "strictModeHint": "Modo rigoroso: erros bloqueiam o progresso" +} diff --git a/apps/web/src/i18n/locales/pt-BR/settings.json b/apps/web/src/i18n/locales/pt-BR/settings.json new file mode 100644 index 0000000..e3ca90a --- /dev/null +++ b/apps/web/src/i18n/locales/pt-BR/settings.json @@ -0,0 +1,9 @@ +{ + "title": "Configurações", + "uiLocale": "Idioma da interface", + "contentLocale": "Idioma do conteúdo das lições", + "strictMode": "Modo rigoroso", + "strictModeDesc": "Bloquear digitação quando um caractere estiver incorreto", + "theme": "Tema", + "saved": "Configurações salvas" +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css new file mode 100644 index 0000000..7d88242 --- /dev/null +++ b/apps/web/src/index.css @@ -0,0 +1,23 @@ +@import 'tailwindcss'; + +@theme { + --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, monospace; + --color-surface: #0f1419; + --color-surface-elevated: #1a2332; + --color-border: #2d3a4f; + --color-accent: #3b82f6; + --color-accent-hover: #2563eb; + --color-success: #22c55e; + --color-error: #ef4444; + --color-muted: #94a3b8; + --color-foreground: #e2e8f0; +} + +body { + @apply m-0 min-h-screen bg-surface font-sans text-foreground antialiased; +} + +#root { + @apply min-h-screen; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..a41871f --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,14 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { AppProviders } from './app/providers'; +import { AppRouter } from './app/router'; +import './index.css'; +import './i18n'; + +createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/apps/web/src/shared/components/Layout.tsx b/apps/web/src/shared/components/Layout.tsx new file mode 100644 index 0000000..67d770b --- /dev/null +++ b/apps/web/src/shared/components/Layout.tsx @@ -0,0 +1,54 @@ +import { Link, NavLink } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { cn } from '@/shared/lib/cn'; + +export function Header() { + const { t } = useTranslation('common'); + + return ( +
+
+ + ⌨ + {t('appName')} + + + +
+
+ ); +} + +export function Layout({ children }: { children: React.ReactNode }) { + return ( +
+
+
{children}
+
+ ); +} diff --git a/apps/web/src/shared/components/ui.tsx b/apps/web/src/shared/components/ui.tsx new file mode 100644 index 0000000..af4d54d --- /dev/null +++ b/apps/web/src/shared/components/ui.tsx @@ -0,0 +1,32 @@ +import { cn } from '@/shared/lib/cn'; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'ghost'; +} + +export function Button({ className, variant = 'primary', ...props }: ButtonProps) { + return ( +