lingo-free.openapps.xyz

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.
Owned app · rev 8af89d2cc9f8
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.
← All files
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
8af89d2cc9 · 17 hours ago
Bind agent address in policy
0018350275 · 19 hours ago
Use OpenApps service URLs and add contribution strip
805365daf4 · 19 hours ago
Bounded source sync
3f2fb3b265 · 2 days ago
Bounded source sync
7dcb580324 · 2 days ago
Bounded source sync
dcd05c0944 · 2 days ago