Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 7efc97d0dde0b2a3e729ccf9
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/Learn.tsx
// The learning path: units with a tip, lessons as circles that open one after another, and a jump
// test to skip ahead. Also the course picker.
import { useEffect, useRef, useState } from "react";
import { LANGUAGES, type Course, type LangId, type Unit } from "../functions/lib/course";
import { useCourse, indexOf } from "./lib/courses";
import { navigate } from "./lib/router";
import { useLearner, update, course as courseOf } from "./lib/progress";
import { Modal } from "./ui";
import { go } from "./Shell";
import { Check, Lock, Star, Book, Arrow } from "./icons";
export function Learn({ lang }: { lang: LangId | null }) {
const s = useLearner();
const id = lang || s.course;
useEffect(() => {
if (lang && s.course !== lang) update((x) => { x.course = lang; });
}, [lang, s.course]);
if (!id) return <Courses />;
return <PathView id={id} />;
}
function PathView({ id }: { id: LangId }) {
const course = useCourse(id);
if (course === "error") return <p className="notice notice-error">This course could not load. Check your connection and try again.</p>;
if (!course) return <div className="loading"><span className="spinner" /> Loading the course...</div>;
return <CoursePath course={course} />;
}
function CoursePath({ course }: { course: Course }) {
const s = useLearner();
const cs = courseOf(s, course.id);
const index = indexOf(course);
const current = index.steps.findIndex((st) => !cs.done[st.key]);
const [open, setOpen] = useState<string | null>(null);
const [tip, setTip] = useState<Unit | null>(null);
const currentRef = useRef<HTMLLIElement>(null);
const done = index.steps.filter((st) => cs.done[st.key]).length;
useEffect(() => {
const el = currentRef.current;
if (el && el.getBoundingClientRect().bottom > window.innerHeight - 90) el.scrollIntoView({ block: "center" });
}, []);
return (
<div className="learn">
<div className="course-title">
<div>
<p className="eyebrow">{course.level} · {done} of {index.steps.length} lessons</p>
<h1 className="h1">{course.name}</h1>
</div>
<a className="btn btn-line btn-sm" href="/courses" onClick={(e) => go(e, "/courses")}>Change course</a>
</div>
{course.units.map((unit, ui) => {
const first = index.steps.findIndex((st) => st.u === unit.n);
const unitDone = unit.lessons.every((l) => cs.done[`${unit.n}.${l.n}`]);
const locked = current !== -1 && current < first;
return (
<section className={`unit ${locked ? "locked" : ""}`} key={unit.n} aria-labelledby={`unit-${unit.n}`}>
<header className="unit-head">
<div className="unit-text">
<p className="eyebrow">Unit {unit.n}{unitDone ? " · Done" : ""}</p>
<h2 className="unit-title" id={`unit-${unit.n}`}>{unit.title}</h2>
<p className="unit-about">{unit.about}</p>
<button type="button" className="unit-tip" onClick={() => setTip(unit)}>
<Book size={16} /> Tip: {unit.tip.title}
</button>
</div>
{locked && (
<a className="btn btn-sm jump-btn" href={`/learn/${course.id}/jump/${unit.n}`} onClick={(e) => go(e, `/learn/${course.id}/jump/${unit.n}`)}>
Jump here
</a>
)}
</header>
<ol className="path">
{unit.lessons.map((lesson, li) => {
const key = `${unit.n}.${lesson.n}`;
const at = first + li;
const state = cs.done[key] ? "done" : at === current ? "current" : "locked";
const offset = [0, 46, 70, 46, 0][li] * (ui % 2 ? -1 : 1);
const href = `/learn/${course.id}/lesson/${unit.n}/${lesson.n}`;
return (
<li key={key} className={`node-row ${state === "current" ? "is-current" : ""} ${open === key ? "open" : ""}`} ref={state === "current" ? currentRef : undefined}>
<div className="node-wrap" style={{ transform: `translateX(${offset}px)` }}>
{state === "current" && open !== key && <span className="node-start" aria-hidden="true">Start</span>}
<button type="button" className={`node ${state}`} aria-expanded={open === key}
aria-label={`Lesson ${lesson.n}: ${lesson.title}${state === "done" ? ", done" : state === "locked" ? ", locked" : ""}`}
onClick={() => setOpen(open === key ? null : key)}>
{state === "done" ? <Check size={30} /> : state === "current" ? <Star size={28} /> : <Lock size={24} />}
</button>
</div>
{open === key && (
<div className={`node-pop ${state}`} role="dialog" aria-label={lesson.title}>
<p className="node-pop-title">{lesson.title}</p>
<p className="node-pop-sub">Lesson {lesson.n} of {unit.lessons.length}</p>
{state === "locked" ? (
<>
<p className="node-pop-sub">Finish the lessons above to open this one.</p>
{locked && <a className="btn btn-line btn-sm btn-block" href={`/learn/${course.id}/jump/${unit.n}`} onClick={(e) => go(e, `/learn/${course.id}/jump/${unit.n}`)}>Jump to unit {unit.n}</a>}
</>
) : (
<a className="btn btn-block" href={href} onClick={(e) => go(e, href)}>
{state === "done" ? "Practice again, +10 XP" : "Start, +10 XP"}
</a>
)}
</div>
)}
</li>
);
})}
</ol>
</section>
);
})}
<div className="course-end">
{current === -1 ? (
<p className="notice notice-ok">You finished every lesson in {course.name}. Practice keeps the words fresh.</p>
) : course.units.length < 8 ? (
<p className="hint">More {course.name} units are on the way. Spanish and French have eight full units today.</p>
) : null}
</div>
{tip && (
<Modal title={tip.tip.title} onClose={() => setTip(null)}>
<div className="tip">
<p className="eyebrow">Unit {tip.n}: {tip.title}</p>
{tip.tip.body.map((p, i) => <p key={i}>{p}</p>)}
<ul className="tip-examples">
{tip.tip.examples.map(([t, e]) => <li key={t}><strong lang={course.id}>{t}</strong><span>{e}</span></li>)}
</ul>
</div>
</Modal>
)}
</div>
);
}
export function Courses() {
const s = useLearner();
const choose = (id: LangId) => {
update((x) => { x.course = id; });
navigate(`/learn/${id}`);
};
return (
<div className="courses">
<h1 className="h1">Pick a language</h1>
<p className="lede">Every course is free from start to finish. Switch any time; each keeps its own progress.</p>
<ul className="course-grid">
{LANGUAGES.map((l) => {
const done = Object.keys(courseOf(s, l.id).done).length;
return (
<li key={l.id}>
<button type="button" className={`course-card ${s.course === l.id ? "current" : ""}`} onClick={() => choose(l.id)}>
<span className="course-code" aria-hidden="true">{l.id.toUpperCase()}</span>
<span className="course-name">{l.name}</span>
<span className="course-meta">{l.native} · {l.units} units{done ? ` · ${done} of ${l.units * 5} lessons done` : ""}</span>
<span className="course-go">{done ? "Continue" : "Start"} <Arrow size={16} /></span>
</button>
</li>
);
})}
</ul>
</div>
);
}
Change history
Point OpenApps links to openapps.free
Bind agent address in policy
Use OpenApps service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync