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