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/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
Bind agent address in policy
Use OpenApps service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync