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/Leagues.tsx
// The weekly league: everyone's XP this week, top learners first, and where you stand.
// Rows are public; the display name is whatever the learner picks.
import { useEffect, useState } from "react";
import { get, count } from "@bounded-sh/client";
import { useLearner } from "./lib/progress";
import { startSync, weekStart, rename } from "./lib/sync";
import { Modal, toast } from "./ui";
import { go } from "./Shell";
import { Shield } from "./icons";

interface Row { id: string; name: string; xp: number; lang: string; hidden?: boolean }
interface Board { top: Row[]; mine: Row | null; rank: number | null; total: number }

function timeLeft(w: number): string {
  const s = w + 604800 - Date.now() / 1000;
  const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600);
  return d >= 1 ? `${d} ${d === 1 ? "day" : "days"} left` : `${Math.max(1, h)} ${h === 1 ? "hour" : "hours"} left`;
}

export function Leagues() {
  const s = useLearner();
  const [board, setBoard] = useState<Board | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [me, setMe] = useState<string | null>(null);
  const [editing, setEditing] = useState(false);
  const w = weekStart();

  useEffect(() => {
    let live = true;
    const load = async () => {
      try {
        const m = await startSync();
        const path = `weeks/${w}/learners`;
        const [res, mine, total] = await Promise.all([
          get(path, { filter: { hidden: false }, sort: { xp: -1 }, limit: 30 }),
          get(`${path}/${m.id}`),
          count(path, { filter: { hidden: false } }),
        ]);
        const top = ((res?.data || []) as Row[]).map((r) => ({ ...r, id: String(r.id ?? "") }));
        const visible = mine && !mine.hidden ? { ...mine, id: m.id } as Row : null;
        const above = visible ? (await count(path, { filter: { hidden: false, xp: { $gt: visible.xp } } })).value : 0;
        if (live) { setMe(m.id); setBoard({ top, mine: visible, rank: visible ? above + 1 : null, total: total.value }); setError(null); }
      } catch (err) {
        if (live) setError("The league could not load. Check your connection.");
      }
    };
    void load();
    const t = window.setInterval(load, 30000);
    return () => { live = false; window.clearInterval(t); };
  }, [w, s.board.xp, s.name]);

  const inTop = board?.mine && board.top.some((r) => r.id === me);
  return (
    <div className="leagues">
      <div className="league-head">
        <span className="league-badge"><Shield size={30} /></span>
        <div>
          <h1 className="h1">This week's league</h1>
          <p className="muted">Everyone learning on Lingo, ranked by XP this week. {timeLeft(w)}; it starts over on Monday.</p>
        </div>
      </div>
      <div className="league-you card">
        <div>
          <p className="eyebrow">You appear as</p>
          <p className="league-name">{s.name}</p>
        </div>
        <button type="button" className="btn btn-line btn-sm" onClick={() => setEditing(true)}>Change name</button>
      </div>
      {error && <p className="notice notice-error">{error}</p>}
      {!board && !error && <div className="loading"><span className="spinner" /> Loading the board...</div>}
      {board && (
        <>
          {board.mine ? (
            <p className="league-rank">You are <strong>#{board.rank}</strong> of {board.total} with <strong>{board.mine.xp} XP</strong>.</p>
          ) : (
            <div className="empty">
              <p className="h3">Finish a lesson to join this week's board.</p>
              <a className="btn" href="/learn" onClick={(e) => go(e, "/learn")}>Start a lesson</a>
            </div>
          )}
          {board.top.length > 0 && (
            <ol className="board">
              {board.top.map((r) => {
                const rank = board.top.findIndex((x) => x.xp === r.xp) + 1;
                return (
                <li key={r.id} className={`${r.id === me ? "me" : ""} ${rank <= 3 ? `top top-${rank}` : ""}`}>
                  <span className="board-rank">{rank}</span>
                  <span className="board-name">{r.name}{r.id === me && <span className="you-tag">You</span>}</span>
                  <span className="board-lang">{String(r.lang || "").toUpperCase()}</span>
                  <span className="board-xp">{r.xp} XP</span>
                </li>
                );
              })}
              {board.mine && !inTop && (
                <li className="me gap">
                  <span className="board-rank">{board.rank}</span>
                  <span className="board-name">{board.mine.name}<span className="you-tag">You</span></span>
                  <span className="board-lang">{board.mine.lang.toUpperCase()}</span>
                  <span className="board-xp">{board.mine.xp} XP</span>
                </li>
              )}
            </ol>
          )}
          <p className="hint">League XP counts up to 600 an hour, so the board stays fair. Every lesson and practice counts.</p>
        </>
      )}
      {editing && <NameEditor current={s.name} onClose={() => setEditing(false)} />}
    </div>
  );
}

export function NameEditor({ current, onClose }: { current: string; onClose: () => void }) {
  const [name, setName] = useState(current);
  const [busy, setBusy] = useState(false);
  const clean = name.replace(/\s+/g, " ").trim();
  const valid = clean.length >= 2 && clean.length <= 24 && /^[\p{L}\p{N} .'_-]+$/u.test(clean);
  const save = async () => {
    if (!valid) return;
    setBusy(true);
    const err = await rename(clean);
    setBusy(false);
    if (err) toast(err); else { toast("Name saved."); onClose(); }
  };
  return (
    <Modal title="Your name on the board" onClose={onClose} footer={
      <>
        <button type="button" className="btn" disabled={!valid || busy} onClick={save}>{busy ? "Saving..." : "Save"}</button>
        <button type="button" className="btn btn-quiet" onClick={onClose}>Cancel</button>
      </>
    }>
      <form className="stack" onSubmit={(e) => { e.preventDefault(); void save(); }}>
        <label className="field">
          <span>Display name</span>
          <input className="input" value={name} maxLength={24} onChange={(e) => setName(e.target.value)} autoComplete="off" />
          <small>2 to 24 letters or numbers. Anyone can see it, so a nickname is best.</small>
        </label>
      </form>
    </Modal>
  );
}

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