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/Shell.tsx
// The app around every page: routes, the stats in the header, the tab bar on phones and the side
// navigation and cards on wide screens. Lessons take the whole screen.
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { isLang, LANGUAGES, type LangId } from "../functions/lib/course";
import { navigate } from "./lib/router";
import { takeLoginError } from "./lib/bounded";
import { startSync, takeUpgraded } from "./lib/sync";
import { useLearner, streak, xpToday, getState, update, settle } from "./lib/progress";
import { toast } from "./ui";
import { Flame, Bolt, Path, Dumbbell, Shield, Person } from "./icons";
import { Learn, Courses } from "./Learn";
import { Session } from "./Session";
import { Practice, Words } from "./Practice";
import { Leagues } from "./Leagues";
import { Profile, GoalCard, StreakCard, AccountCard } from "./Profile";

export type SessionKind = "lesson" | "jump" | "practice" | "mistakes";
export type Route =
  | { name: "learn"; lang: LangId | null }
  | { name: "courses" }
  | { name: "session"; kind: SessionKind; lang: LangId | null; u: number; l: number }
  | { name: "practice" }
  | { name: "words" }
  | { name: "leagues" }
  | { name: "profile" };

export function routeOf(path: string): Route | null {
  const p = path.replace(/\/+$/, "").split("/").filter(Boolean);
  if (!p.length) return null;
  const lang = p[1] && isLang(p[1]) ? p[1] : null;
  if (p[0] === "learn") {
    if (p.length === 1) return { name: "learn", lang: null };
    if (!lang) return { name: "learn", lang: null };
    if (p[2] === "lesson" && p.length === 5) return { name: "session", kind: "lesson", lang, u: Number(p[3]) || 1, l: Number(p[4]) || 1 };
    if (p[2] === "jump" && p.length === 4) return { name: "session", kind: "jump", lang, u: Number(p[3]) || 2, l: 0 };
    return { name: "learn", lang };
  }
  if (p[0] === "courses") return { name: "courses" };
  if (p[0] === "practice" && (p[1] === "review" || p[1] === "mistakes")) return { name: "session", kind: p[1] === "review" ? "practice" : "mistakes", lang: null, u: 0, l: 0 };
  if (p[0] === "practice") return { name: "practice" };
  if (p[0] === "words") return { name: "words" };
  if (p[0] === "leagues") return { name: "leagues" };
  if (p[0] === "profile") return { name: "profile" };
  return { name: "learn", lang: null };
}

const NAV = [
  { href: "/learn", label: "Learn", Icon: Path, match: ["learn", "courses"] },
  { href: "/practice", label: "Practice", Icon: Dumbbell, match: ["practice", "words"] },
  { href: "/leagues", label: "Leagues", Icon: Shield, match: ["leagues"] },
  { href: "/profile", label: "Profile", Icon: Person, match: ["profile"] },
];

export function go(e: React.MouseEvent, href: string) {
  e.preventDefault();
  navigate(href);
}

function HeadStats() {
  const s = useLearner();
  const slot = document.getElementById("head-stats");
  if (!slot) return null;
  const lang = LANGUAGES.find((l) => l.id === s.course);
  const n = streak(s);
  const active = xpToday(s) > 0;
  return createPortal(
    <div className="head-stats">
      {lang && <a className="stat course-chip" href="/courses" onClick={(e) => go(e, "/courses")} aria-label={`Course: ${lang.name}. Change course`}>{lang.id.toUpperCase()}</a>}
      <a className={`stat streak ${active ? "on" : ""}`} href="/profile" onClick={(e) => go(e, "/profile")} aria-label={`${n} day streak`}><Flame size={19} /> {n}</a>
      <a className="stat xp" href="/profile" onClick={(e) => go(e, "/profile")} aria-label={`${xpToday(s)} of ${s.goal} XP today`}><Bolt size={18} /> {xpToday(s)}<span className="stat-of">/{s.goal}</span></a>
    </div>,
    slot,
  );
}

function Nav({ route, className }: { route: Route; className: string }) {
  return (
    <nav className={className} aria-label="Lingo">
      {NAV.map(({ href, label, Icon, match }) => (
        <a key={href} href={href} onClick={(e) => go(e, href)} aria-current={match.includes(route.name) ? "page" : undefined}>
          <Icon /> <span>{label}</span>
        </a>
      ))}
    </nav>
  );
}

export function Shell({ route }: { route: Route }) {
  const [ready, setReady] = useState(false);

  useEffect(() => {
    let live = true;
    const fallback = window.setTimeout(() => live && setReady(true), 3500);
    startSync()
      .then(() => {
        if (takeUpgraded()) toast("Your progress is saved to your account. It is on every device you sign in on.");
        const err = takeLoginError();
        if (err) toast(err);
      })
      .catch(() => toast("You seem to be offline. Lessons still work, and progress is saved on this device."))
      .finally(() => { if (live) setReady(true); window.clearTimeout(fallback); });
    // A new day may need a streak freeze.
    const onFocus = () => { const s = structuredClone(getState()); if (settle(s)) update((x) => { x.frozen = s.frozen; x.freezes = s.freezes; }); };
    window.addEventListener("focus", onFocus);
    return () => { live = false; window.removeEventListener("focus", onFocus); };
  }, []);

  useEffect(() => {
    const titles: Record<string, string> = { learn: "Learn", courses: "Courses", practice: "Practice", words: "Words", leagues: "Leagues", profile: "Profile", session: "Lesson" };
    document.title = `${titles[route.name]} | Lingo`;
  }, [route.name]);

  if (!ready) return <div className="wrap page-pad"><div className="loading"><span className="spinner" /> Opening Lingo...</div></div>;

  if (route.name === "session") {
    const lang = route.lang || getState().course;
    if (!lang) { navigate("/courses"); return null; }
    return <Session key={`${route.kind}.${lang}.${route.u}.${route.l}`} kind={route.kind} lang={lang} u={route.u} l={route.l} />;
  }

  let page: React.ReactNode;
  if (route.name === "learn") page = <Learn lang={route.lang} />;
  else if (route.name === "courses") page = <Courses />;
  else if (route.name === "practice") page = <Practice />;
  else if (route.name === "words") page = <Words />;
  else if (route.name === "leagues") page = <Leagues />;
  else page = <Profile />;

  const side = route.name !== "profile";
  return (
    <div className="shell">
      <HeadStats />
      <Nav route={route} className="side-nav" />
      <div className="shell-main">{page}</div>
      {side && (
        <aside className="shell-side">
          <GoalCard />
          <StreakCard />
          <AccountCard compact />
        </aside>
      )}
      <Nav route={route} className="tab-bar" />
    </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