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/Exercises.tsx
// One component per exercise type. Each reports its current answer (or null) to the session, which
// checks it; `locked` is true while the result sheet is showing.
import { useEffect, useMemo, useRef, useState } from "react";
import type { Ex } from "./lib/exercises";
import { fold } from "./lib/answer";
import { say, listen, pauseSpeaking } from "./lib/speech";
import { Speaker, Slow, Mic } from "./icons";

export interface ViewProps<K extends Ex["kind"]> {
  ex: Extract<Ex, { kind: K }>;
  lang: string; // course id, for lang attributes
  speech: string; // voice code
  hints: Map<string, [string, string][]>;
  locked: boolean;
  onAnswer: (value: string | null) => void;
  onSkip?: () => void;
  onMiss?: (items: string[]) => void;
}

const CHARS: Record<string, string[]> = {
  es: ["á", "é", "í", "ó", "ú", "ñ", "ü", "¿", "¡"],
  fr: ["à", "â", "ç", "é", "è", "ê", "ë", "î", "ï", "ô", "û", "ù", "œ"],
  de: ["ä", "ö", "ü", "ß"],
  it: ["à", "è", "é", "ì", "ò", "ù"],
  pt: ["á", "â", "ã", "à", "ç", "é", "ê", "í", "ó", "ô", "õ", "ú"],
};

function Listen({ text, speech, big }: { text: string; speech: string; big?: boolean }) {
  return (
    <span className={`listen-btns ${big ? "big" : ""}`}>
      <button type="button" className="say" onClick={() => say(text, speech)} aria-label="Play">
        <Speaker size={big ? 34 : 20} />
      </button>
      {big && (
        <button type="button" className="say slow" onClick={() => say(text, speech, true)} aria-label="Play slowly">
          <Slow size={24} />
        </button>
      )}
    </span>
  );
}

/** Target-language text; tap a word to see what it means. */
export function TargetText({ text, lang, hints }: { text: string; lang: string; hints: Map<string, [string, string][]> }) {
  const [open, setOpen] = useState<number | null>(null);
  const parts = text.split(/([\s ]+)/);
  return (
    <span className="target-text" lang={lang}>
      {parts.map((p, i) => {
        if (!p.trim()) return p;
        const bare = p.replace(/^[¿¡"“«(]+|[?!.,;:"”»)…]+$/g, "");
        const found = hints.get(fold(bare.toLowerCase())) || bare.split(/['’]/).flatMap((x) => hints.get(fold(x.toLowerCase())) || []);
        if (!found.length) return <span key={i}>{p}</span>;
        return (
          <span key={i} className="hint-word">
            <button type="button" onClick={() => setOpen(open === i ? null : i)} aria-expanded={open === i}>{p}</button>
            {open === i && (
              <span className="hint-pop" role="tooltip">
                {found.slice(0, 3).map(([w, e]) => <span key={w}><b lang={lang}>{w}</b> {e}</span>)}
              </span>
            )}
          </span>
        );
      })}
    </span>
  );
}

function Prompt({ children, audio, speech }: { children: React.ReactNode; audio?: string; speech: string }) {
  return (
    <div className="prompt">
      {audio && <Listen text={audio} speech={speech} />}
      <p className="prompt-text">{children}</p>
    </div>
  );
}

export function PickView({ ex, lang, speech, locked, onAnswer }: ViewProps<"pick">) {
  const [chosen, setChosen] = useState<string | null>(null);
  useEffect(() => { if (ex.dir === "t2e") say(ex.prompt, speech); }, [ex, speech]);
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      const n = Number(e.key);
      if (!locked && n >= 1 && n <= ex.options.length && !(e.target instanceof HTMLInputElement)) choose(ex.options[n - 1]);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  });
  const choose = (o: string) => {
    if (locked) return;
    setChosen(o);
    onAnswer(o);
    if (ex.dir === "e2t") say(o, speech);
  };
  return (
    <div className="ex">
      <h2 className="ex-title">{ex.dir === "t2e" ? "What does this mean?" : `Which one means "${ex.prompt}"?`}</h2>
      {ex.dir === "t2e" && <Prompt audio={ex.prompt} speech={speech}><span lang={lang}>{ex.prompt}</span></Prompt>}
      <div className="options" role="radiogroup">
        {ex.options.map((o, i) => (
          <button key={o} type="button" role="radio" aria-checked={chosen === o} className={`option ${chosen === o ? "chosen" : ""}`}
            disabled={locked} onClick={() => choose(o)} lang={ex.dir === "e2t" ? lang : undefined}>
            <span className="option-key" aria-hidden="true">{i + 1}</span>{o}
          </button>
        ))}
      </div>
    </div>
  );
}

export function MatchView({ ex, lang, speech, locked, onAnswer, onMiss }: ViewProps<"match">) {
  const right = useMemo(() => [...ex.pairs].sort((a, b) => a.e.localeCompare(b.e)), [ex]);
  const [left, setLeft] = useState<string | null>(null);
  const [rightSel, setRightSel] = useState<string | null>(null);
  const [matched, setMatched] = useState<string[]>([]);
  const [wrong, setWrong] = useState<string[]>([]);
  useEffect(() => {
    if (!left || !rightSel) return;
    const pair = ex.pairs.find((p) => p.t === left);
    if (pair && pair.e === rightSel) {
      const next = [...matched, left];
      setMatched(next);
      if (next.length === ex.pairs.length) onAnswer("done");
    } else {
      const other = ex.pairs.find((p) => p.e === rightSel);
      onMiss?.([pair?.item, other?.item].filter(Boolean) as string[]);
      setWrong([left, rightSel]);
      window.setTimeout(() => setWrong([]), 450);
    }
    setLeft(null);
    setRightSel(null);
  }, [left, rightSel, ex, matched, onAnswer, onMiss]);
  const done = (t: string) => matched.includes(t);
  const doneE = (e: string) => ex.pairs.some((p) => p.e === e && matched.includes(p.t));
  return (
    <div className="ex">
      <h2 className="ex-title">Match the pairs</h2>
      <div className="match">
        <div className="match-col">
          {ex.pairs.map((p) => (
            <button key={p.t} type="button" lang={lang} disabled={locked || done(p.t)}
              className={`tile-btn ${left === p.t ? "chosen" : ""} ${done(p.t) ? "matched" : ""} ${wrong.includes(p.t) ? "miss" : ""}`}
              onClick={() => { setLeft(p.t); say(p.t, speech); }}>{p.t}</button>
          ))}
        </div>
        <div className="match-col">
          {right.map((p) => (
            <button key={p.e} type="button" disabled={locked || doneE(p.e)}
              className={`tile-btn ${rightSel === p.e ? "chosen" : ""} ${doneE(p.e) ? "matched" : ""} ${wrong.includes(p.e) ? "miss" : ""}`}
              onClick={() => setRightSel(p.e)}>{p.e}</button>
          ))}
        </div>
      </div>
    </div>
  );
}

function Tiles({ tiles, lang, locked, onAnswer, speech, speakTiles }: { tiles: string[]; lang?: string; locked: boolean; onAnswer: (v: string | null) => void; speech: string; speakTiles: boolean }) {
  const [picked, setPicked] = useState<number[]>([]);
  useEffect(() => { onAnswer(picked.length ? picked.map((i) => tiles[i]).join(" ") : null); }, [picked]); // eslint-disable-line react-hooks/exhaustive-deps
  return (
    <div className="build">
      <div className="answer-line" aria-label="Your answer">
        {picked.map((i) => (
          <button key={i} type="button" className="tile" lang={lang} disabled={locked} onClick={() => setPicked((p) => p.filter((x) => x !== i))}>{tiles[i]}</button>
        ))}
      </div>
      <div className="bank" aria-label="Word tiles">
        {tiles.map((t, i) => (
          <span key={i} className="tile-slot">
            <button type="button" className={`tile ${picked.includes(i) ? "used" : ""}`} lang={lang} disabled={locked || picked.includes(i)}
              onClick={() => { setPicked((p) => [...p, i]); if (speakTiles) say(t, speech); }}>{t}</button>
          </span>
        ))}
      </div>
    </div>
  );
}

export function BuildView({ ex, lang, speech, hints, locked, onAnswer }: ViewProps<"build">) {
  useEffect(() => { if (ex.dir === "t2e") say(ex.prompt, speech); }, [ex, speech]);
  return (
    <div className="ex">
      <h2 className="ex-title">{ex.dir === "t2e" ? "Write this in English" : `Write this in ${LANG_NAME[lang] || "the language"}`}</h2>
      {ex.dir === "t2e"
        ? <Prompt audio={ex.prompt} speech={speech}><TargetText text={ex.prompt} lang={lang} hints={hints} /></Prompt>
        : <Prompt speech={speech}>{ex.prompt}</Prompt>}
      <Tiles tiles={ex.tiles} lang={ex.dir === "e2t" ? lang : undefined} locked={locked} onAnswer={onAnswer} speech={speech} speakTiles={ex.dir === "e2t"} />
    </div>
  );
}

const LANG_NAME: Record<string, string> = { es: "Spanish", fr: "French", de: "German", it: "Italian", pt: "Portuguese" };

function TypeBox({ lang, locked, onAnswer, placeholder }: { lang?: string; locked: boolean; onAnswer: (v: string | null) => void; placeholder: string }) {
  const [value, setValue] = useState("");
  const ref = useRef<HTMLTextAreaElement>(null);
  useEffect(() => { if (matchMedia("(pointer: fine)").matches) ref.current?.focus(); }, []);
  const change = (v: string) => { setValue(v); onAnswer(v.trim() ? v : null); };
  const insert = (c: string) => {
    const el = ref.current;
    if (!el) return change(value + c);
    const a = el.selectionStart ?? value.length, b = el.selectionEnd ?? value.length;
    change(value.slice(0, a) + c + value.slice(b));
    requestAnimationFrame(() => { el.focus(); el.setSelectionRange(a + c.length, a + c.length); });
  };
  return (
    <div className="type-box">
      <textarea ref={ref} className="textarea answer-input" value={value} lang={lang} disabled={locked} placeholder={placeholder}
        autoCapitalize="off" autoCorrect="off" autoComplete="off" spellCheck={false} rows={3}
        onChange={(e) => change(e.target.value)}
        onKeyDown={(e) => { if (e.key === "Enter") e.preventDefault(); }} />
      {lang && CHARS[lang] && (
        <div className="chars" aria-label="Special letters">
          {CHARS[lang].map((c) => <button key={c} type="button" className="char" disabled={locked} onClick={() => insert(c)}>{c}</button>)}
        </div>
      )}
    </div>
  );
}

export function TypeView({ ex, lang, speech, hints, locked, onAnswer }: ViewProps<"type">) {
  useEffect(() => { if (ex.dir === "t2e") say(ex.prompt, speech); }, [ex, speech]);
  return (
    <div className="ex">
      <h2 className="ex-title">{ex.dir === "t2e" ? "Translate into English" : `Translate into ${LANG_NAME[lang] || "the language"}`}</h2>
      {ex.dir === "t2e"
        ? <Prompt audio={ex.prompt} speech={speech}><TargetText text={ex.prompt} lang={lang} hints={hints} /></Prompt>
        : <Prompt speech={speech}>{ex.prompt}</Prompt>}
      <TypeBox lang={ex.dir === "e2t" ? lang : undefined} locked={locked} onAnswer={onAnswer} placeholder={ex.dir === "t2e" ? "Type in English" : `Type in ${LANG_NAME[lang] || "the language"}`} />
    </div>
  );
}

export function ListenView({ ex, lang, speech, locked, onAnswer, onSkip }: ViewProps<"listen">) {
  useEffect(() => { const t = window.setTimeout(() => say(ex.text, speech), 250); return () => window.clearTimeout(t); }, [ex, speech]);
  return (
    <div className="ex">
      <h2 className="ex-title">{ex.tiles ? "Tap what you hear" : "Type what you hear"}</h2>
      <div className="listen-stage"><Listen text={ex.text} speech={speech} big /></div>
      {ex.tiles
        ? <Tiles tiles={ex.tiles} lang={lang} locked={locked} onAnswer={onAnswer} speech={speech} speakTiles={false} />
        : <TypeBox lang={lang} locked={locked} onAnswer={onAnswer} placeholder={`Type in ${LANG_NAME[lang] || "the language"}`} />}
      {!locked && onSkip && <button type="button" className="btn btn-quiet btn-sm cant" onClick={onSkip}>Can't listen now</button>}
    </div>
  );
}

export function BlankView({ ex, lang, speech, locked, onAnswer }: ViewProps<"blank">) {
  const [chosen, setChosen] = useState<string | null>(null);
  const choose = (o: string) => { if (locked) return; setChosen(o); onAnswer(o); say(o, speech); };
  return (
    <div className="ex">
      <h2 className="ex-title">Fill in the blank</h2>
      <p className="blank-sentence" lang={lang}>
        {ex.before}<span className={`blank-slot ${chosen ? "filled" : ""}`}>{chosen || " "}</span>{ex.after}
      </p>
      <p className="blank-meaning">{ex.translation}</p>
      <div className="chips-row">
        {ex.options.map((o) => (
          <button key={o} type="button" className={`tile ${chosen === o ? "used" : ""}`} lang={lang} disabled={locked} onClick={() => choose(o)}>{o}</button>
        ))}
      </div>
    </div>
  );
}

export function SpeakView({ ex, lang, speech, hints, locked, onAnswer, onSkip }: ViewProps<"speak">) {
  const [state, setState] = useState<"idle" | "listening" | "heard" | "blocked">("idle");
  const [heard, setHeard] = useState("");
  const stop = useRef<(() => void) | null>(null);
  useEffect(() => () => stop.current?.(), []);
  const start = () => {
    if (locked) return;
    setHeard("");
    setState("listening");
    onAnswer(null);
    const r = listen(speech, (t) => setHeard(t));
    stop.current = r.stop;
    r.done.then((t) => { setHeard(t); setState(t ? "heard" : "idle"); onAnswer(t || null); }, () => setState("blocked"));
  };
  return (
    <div className="ex">
      <h2 className="ex-title">Read this aloud</h2>
      <Prompt audio={ex.text} speech={speech}><TargetText text={ex.text} lang={lang} hints={hints} /></Prompt>
      <div className="speak-stage">
        {state === "blocked" ? (
          <p className="notice">The microphone is blocked for this site. Allow it in your browser settings, or skip speaking for now.</p>
        ) : (
          <button type="button" className={`mic ${state === "listening" ? "on" : ""}`} disabled={locked} onClick={() => (state === "listening" ? stop.current?.() : start())}>
            <Mic size={28} /> {state === "listening" ? "Listening... tap when done" : heard ? "Try again" : "Tap to speak"}
          </button>
        )}
        {heard && <p className="heard" lang={lang}>{heard}</p>}
      </div>
      {!locked && onSkip && <button type="button" className="btn btn-quiet btn-sm cant" onClick={() => { pauseSpeaking(); onSkip(); }}>Can't speak now</button>}
    </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