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/Profile.tsx
// Profile: streak, XP, daily goal, courses, the league name, keeping progress on every device,
// sound, and installing Lingo on the home screen. The cards also sit beside the path on wide screens.
import { useEffect, useState } from "react";
import { LANGUAGES } from "../functions/lib/course";
import { useLearner, update, streak, xpToday, course as courseOf, GOALS, MAX_FREEZES, replace, fresh } from "./lib/progress";
import { keepProgress, currentUser, flush } from "./lib/sync";
import { signOut, explain } from "./lib/bounded";
import { canSpeak, pauseSpeaking } from "./lib/speech";
import { today } from "./lib/srs";
import { navigate } from "./lib/router";
import { toast } from "./ui";
import { NameEditor } from "./Leagues";
import { go } from "./Shell";
import { Flame, Bolt, Snow, Check } from "./icons";

export function GoalCard() {
  const s = useLearner();
  const x = xpToday(s);
  const pct = Math.min(100, (x / s.goal) * 100);
  return (
    <section className="side-card card">
      <div className="spread"><h2 className="h3">Daily goal</h2><span className="muted small">{x} / {s.goal} XP</span></div>
      <div className="goal-bar"><span style={{ width: `${pct}%` }} /></div>
      <p className="muted small">{x >= s.goal ? "Goal reached today. Nice work." : `${s.goal - x} XP to go today.`}</p>
    </section>
  );
}

function Week() {
  const s = useLearner();
  const day = today();
  const labels = ["S", "M", "T", "W", "T", "F", "S"];
  return (
    <ol className="week" aria-label="This week">
      {Array.from({ length: 7 }, (_, i) => day - 6 + i).map((d) => {
        const date = new Date(d * 86400000);
        const on = (s.days[d] || 0) > 0;
        const frozen = s.frozen.includes(d);
        return (
          <li key={d} className={`${on ? "on" : ""} ${frozen ? "frozen" : ""} ${d === day ? "today" : ""}`} aria-label={`${date.toUTCString().slice(0, 11)}: ${on ? "practiced" : frozen ? "kept by a freeze" : "no practice"}`}>
            <span className="week-dot">{on ? <Check size={14} /> : frozen ? <Snow size={13} /> : null}</span>
            <span className="week-day">{labels[date.getUTCDay()]}</span>
          </li>
        );
      })}
    </ol>
  );
}

export function StreakCard() {
  const s = useLearner();
  const n = streak(s);
  const active = xpToday(s) > 0;
  return (
    <section className="side-card card">
      <div className="streak-line">
        <span className={`streak-flame ${active ? "on" : ""}`}><Flame size={28} /></span>
        <div>
          <p className="streak-n">{n} day streak</p>
          <p className="muted small">{active ? "You practiced today." : n ? "Do a lesson today to keep it going." : "Do a lesson to start a streak."}</p>
        </div>
      </div>
      <Week />
      <p className="muted small freeze-line"><Snow size={15} /> {s.freezes} of {MAX_FREEZES} streak freezes. Earn one with a practice review each day.</p>
    </section>
  );
}

export function AccountCard({ compact = false }: { compact?: boolean }) {
  const user = currentUser();
  const [busy, setBusy] = useState(false);
  if (!user) return null;
  if (!user.guest) {
    if (compact) return null;
    const out = async () => {
      setBusy(true);
      try {
        await flush().catch(() => null);
        replace(fresh());
        await signOut();
        location.href = "/learn";
      } catch (err) {
        toast(explain(err));
        setBusy(false);
      }
    };
    return (
      <section className="side-card card">
        <h2 className="h3">Your account</h2>
        <p className="muted small">Signed in{user.email ? ` as ${user.email}` : ""}. Your progress is saved to your account and follows you to every device you sign in on.</p>
        <button type="button" className="btn btn-line btn-sm" disabled={busy} onClick={out}>{busy ? "Signing out..." : "Sign out"}</button>
      </section>
    );
  }
  const keep = async () => {
    setBusy(true);
    try { await keepProgress(); } catch (err) { toast(explain(err)); setBusy(false); }
  };
  return (
    <section className="side-card card account-card">
      <h2 className="h3">Keep my progress on all devices</h2>
      <p className="muted small">Your progress is saved in this browser. Sign in with your email to keep it on your phone, laptop and anywhere else. No password, and it stays free.</p>
      <button type="button" className="btn btn-sm" disabled={busy} onClick={keep}>{busy ? "Opening sign-in..." : "Continue with email"}</button>
    </section>
  );
}

type InstallPrompt = Event & { prompt(): Promise<void>; userChoice: Promise<{ outcome: string }> };
let early: InstallPrompt | null = null;
window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); early = e as InstallPrompt; });

function Install() {
  const [prompt, setPrompt] = useState<InstallPrompt | null>(early);
  const [installed, setInstalled] = useState(() => matchMedia("(display-mode: standalone)").matches);
  useEffect(() => {
    const offer = (e: Event) => { e.preventDefault(); setPrompt(e as InstallPrompt); };
    const done = () => { setInstalled(true); setPrompt(null); };
    window.addEventListener("beforeinstallprompt", offer);
    window.addEventListener("appinstalled", done);
    return () => { window.removeEventListener("beforeinstallprompt", offer); window.removeEventListener("appinstalled", done); };
  }, []);
  if (installed) return <p className="muted small">Lingo is on your home screen.</p>;
  if (prompt) {
    return <button type="button" className="btn btn-line btn-sm" onClick={async () => { await prompt.prompt(); const { outcome } = await prompt.userChoice; if (outcome === "accepted") setInstalled(true); setPrompt(null); }}>Add Lingo to your home screen</button>;
  }
  return <p className="muted small">On iPhone or iPad, tap Share, then Add to Home Screen. In Chrome or Edge, use Install app in the menu.</p>;
}

export function Profile() {
  const s = useLearner();
  const [editing, setEditing] = useState(false);
  const [speaking, setSpeaking] = useState(canSpeak());
  const started = LANGUAGES.filter((l) => Object.keys(courseOf(s, l.id).done).length || s.course === l.id);
  return (
    <div className="profile">
      <div className="profile-head">
        <div>
          <p className="eyebrow">Profile</p>
          <h1 className="h1">{s.name}</h1>
        </div>
        <button type="button" className="btn btn-line btn-sm" onClick={() => setEditing(true)}>Change name</button>
      </div>
      <div className="stat-grid">
        <div className="stat-box"><Flame size={22} className="gold" /><strong>{streak(s)}</strong><span>Day streak</span></div>
        <div className="stat-box"><Bolt size={22} className="cobalt" /><strong>{s.xp}</strong><span>Total XP</span></div>
        <div className="stat-box"><Snow size={22} className="cobalt" /><strong>{s.freezes}</strong><span>Streak freezes</span></div>
        <div className="stat-box"><Check size={22} className="sage" /><strong>{Object.values(s.courses).reduce((a, c) => a + Object.keys(c?.done || {}).length, 0)}</strong><span>Lessons done</span></div>
      </div>
      <div className="profile-grid">
        <StreakCard />
        <section className="side-card card">
          <h2 className="h3">Daily goal</h2>
          <div className="goal-options compact" role="radiogroup" aria-label="Daily goal">
            {GOALS.map((g) => (
              <button key={g.xp} type="button" role="radio" aria-checked={s.goal === g.xp} className={`goal-option ${s.goal === g.xp ? "chosen" : ""}`}
                onClick={() => update((x) => { x.goal = g.xp; x.goalSet = true; })}>
                <strong>{g.name}</strong><span>{g.xp} XP a day</span>
              </button>
            ))}
          </div>
        </section>
        <section className="side-card card">
          <div className="spread"><h2 className="h3">Courses</h2><a href="/courses" onClick={(e) => go(e, "/courses")} className="small">Add a course</a></div>
          <ul className="profile-courses">
            {started.map((l) => {
              const done = Object.keys(courseOf(s, l.id).done).length;
              return (
                <li key={l.id}>
                  <a href={`/learn/${l.id}`} onClick={(e) => { e.preventDefault(); update((x) => { x.course = l.id; }); navigate(`/learn/${l.id}`); }}>
                    <span className="course-code small-code">{l.id.toUpperCase()}</span>
                    <span className="grow"><strong>{l.name}</strong><span className="muted small"> {done} of {l.units * 5} lessons · {courseOf(s, l.id).xp} XP</span></span>
                  </a>
                  <span className="mini-bar"><span style={{ width: `${(done / (l.units * 5)) * 100}%` }} /></span>
                </li>
              );
            })}
          </ul>
        </section>
        <AccountCard />
        <section className="side-card card">
          <h2 className="h3">Settings</h2>
          <label className="check"><input type="checkbox" checked={s.sound} onChange={(e) => update((x) => { x.sound = e.target.checked; })} /> Sound effects</label>
          <label className="check"><input type="checkbox" checked={speaking} onChange={(e) => {
            if (e.target.checked) localStorage.removeItem("lingo:no-speak-until"); else pauseSpeaking();
            setSpeaking(canSpeak());
          }} /> Speaking exercises{speaking || canSpeak() ? "" : " (paused for an hour, or not available in this browser)"}</label>
          <Install />
        </section>
      </div>
      {editing && <NameEditor current={s.name} onClose={() => setEditing(false)} />}
    </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