feat(learner): web shell, library, lesson views (LessonView), decks, code files, exercises, notes, account and admin pages
Tasks 10-12 of the WP-A plan in one commit (the route modules import each other): - header extras portalled into the Phase 0 header: course switcher, search, Reading/Work, account - course frame with outline sidebar (mobile overlay, Escape closes), progress, gating locks - library: featured course, continue row, New and collection rows, course list, filters, search - LessonView: text (track switcher, folds, ordered hint tiers), video (resume, done near the end, timestamp notes, transcript), companion decks rendered with pdf.js (thumbs, arrows, fullscreen, per-slide notes), code files (tree, tabs, copy, zip, command), exercises (Web Worker runner or local command with optional pasted evidence), floating action bar - notes list, account (email/password, providers, SSO by email) and owner admin pages - pdf.js worker served from /api/learner/pdf.worker.mjs - browser entry @stc/feature-learner/web (the package index also carries the server half) Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
99c0e4929f
commit
89c51f013d
@@ -1,4 +1,5 @@
|
||||
import type { WebFeature } from "@stc/contracts";
|
||||
import { learnerWeb } from "@stc/feature-learner/web";
|
||||
|
||||
// Phase 1 packages append one import line each and push into this list.
|
||||
export const webFeatures: WebFeature[] = [];
|
||||
export const webFeatures: WebFeature[] = [learnerWeb];
|
||||
|
||||
@@ -9,5 +9,9 @@
|
||||
"better-auth": "^1.7.14",
|
||||
"marked": "^18.1.2",
|
||||
"pdfjs-dist": "^6.4.299"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./web": "./web/src/index.ts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
// Server
|
||||
export { migrationsDirs, runMigrations } from "./server/migrate";
|
||||
export { learnerFeature } from "./server/feature";
|
||||
// Browser (also available without the server half as "@stc/feature-learner/web")
|
||||
export { learnerWeb, LessonView, type LessonViewProps, ensureLearnerStyles } from "./web";
|
||||
// Shared JSON shapes
|
||||
export type * from "./shared/types";
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Feature } from "@stc/contracts";
|
||||
|
||||
/** The pdf.js worker from the installed pdfjs-dist, for client-side deck rendering. */
|
||||
export function pdfWorkerPath(): string {
|
||||
return Bun.resolveSync("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.dir);
|
||||
}
|
||||
|
||||
export function assetRoutes(): Feature["routes"] {
|
||||
let path: string | null = null;
|
||||
return {
|
||||
"/api/learner/pdf.worker.mjs": {
|
||||
GET: async () => {
|
||||
path ??= pdfWorkerPath();
|
||||
return new Response(Bun.file(path), {
|
||||
headers: { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "public, max-age=86400" },
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { lessonRoutes } from "./lesson";
|
||||
import { userDataRoutes } from "./userdata";
|
||||
import { codeRoutes } from "./code";
|
||||
import { transpileRoutes } from "./transpile";
|
||||
import { assetRoutes } from "./assets";
|
||||
|
||||
/**
|
||||
* The learner feature: auth, library, reader, notes, progress and exercises.
|
||||
@@ -32,6 +33,7 @@ export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthServic
|
||||
...userDataRoutes(ctx),
|
||||
...codeRoutes(ctx),
|
||||
...transpileRoutes(),
|
||||
...assetRoutes(),
|
||||
};
|
||||
// Every handler waits for the learner migrations (a no-op once Phase 2 runs them in migrate()).
|
||||
for (const methods of Object.values(routes)) {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Pill, SegmentedToggle } from "@stc/ui";
|
||||
import { api, clearCourseCache, ensureStyles, refreshLibrary, refreshMe, useApi, useMe } from "../api";
|
||||
import { HeaderExtras } from "../shell/HeaderExtras";
|
||||
|
||||
export interface ProvidersPayload { emailPassword: boolean; providers: { id: string; label: string; kind: "social" | "oidc" }[]; sso: boolean }
|
||||
|
||||
async function afterAuthChange() {
|
||||
clearCourseCache();
|
||||
await Promise.all([refreshMe(), refreshLibrary()]);
|
||||
}
|
||||
|
||||
/** Only same-site paths are allowed as a return target. */
|
||||
export function safeNext(raw: string | null): string {
|
||||
return raw && raw.startsWith("/") && !raw.startsWith("//") ? raw : "/";
|
||||
}
|
||||
|
||||
export default function AccountPage() {
|
||||
ensureStyles();
|
||||
const { loaded, user } = useMe();
|
||||
useEffect(() => { document.title = "Account – STCourses"; }, []);
|
||||
return (
|
||||
<div className="lr" data-look="work">
|
||||
<HeaderExtras course={null} lookMode={null} />
|
||||
<article style={{ maxWidth: 660, margin: "0 auto", paddingBottom: 60 }}>
|
||||
{!loaded ? null : user ? <SignedIn /> : <SignInForms />}
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SignedIn() {
|
||||
const { user } = useMe();
|
||||
const navigate = useNavigate();
|
||||
const [busy, setBusy] = useState(false);
|
||||
if (!user) return null;
|
||||
const signOut = async () => {
|
||||
setBusy(true);
|
||||
try { await api("/api/auth/sign-out", { method: "POST", body: {} }); } catch { /* the session may already be gone */ }
|
||||
await afterAuthChange();
|
||||
setBusy(false);
|
||||
navigate("/");
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<h1 className="lib-h">Your account</h1>
|
||||
<dl className="acct-dl">
|
||||
<dt>Name</dt><dd>{user.name || "—"}</dd>
|
||||
<dt>Email</dt><dd>{user.email}</dd>
|
||||
<dt>Roles</dt><dd>{user.roles.join(", ") || "learner"}</dd>
|
||||
{user.groups.length ? <><dt>Groups</dt><dd>{user.groups.join(", ")}</dd></> : null}
|
||||
</dl>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{user.roles.includes("owner") ? <Link className="stc-pill" to="/account/admin">Users, groups and library</Link> : null}
|
||||
<Pill onClick={signOut} disabled={busy}>Sign out</Pill>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SignInForms() {
|
||||
const loc = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const next = safeNext(new URLSearchParams(loc.search).get("next"));
|
||||
const { data: prov } = useApi<ProvidersPayload>("/api/learner/providers");
|
||||
const [mode, setMode] = useState<"in" | "up">("in");
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [ssoEmail, setSsoEmail] = useState("");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErr(null); setBusy(true);
|
||||
try {
|
||||
if (mode === "in") await api("/api/auth/sign-in/email", { method: "POST", body: { email, password } });
|
||||
else await api("/api/auth/sign-up/email", { method: "POST", body: { name: name.trim() || email.split("@")[0], email, password } });
|
||||
await afterAuthChange();
|
||||
navigate(next);
|
||||
} catch (e) {
|
||||
setErr(mode === "in" ? "That email and password do not match an account." : (e as Error).message);
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
const redirect = async (path: string, body: Record<string, unknown>) => {
|
||||
setErr(null);
|
||||
try {
|
||||
const callbackURL = new URL(next, location.origin).toString();
|
||||
const r = await api<{ url?: string }>(path, { method: "POST", body: { ...body, callbackURL } });
|
||||
if (r?.url) location.assign(r.url); else setErr("The sign-in provider did not answer.");
|
||||
} catch (e) { setErr((e as Error).message); }
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="lib-h">{mode === "in" ? "Sign in" : "Create an account"}</h1>
|
||||
<p className="lib-sub">Your progress, notes and exercise code are kept with your account.</p>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<SegmentedToggle label="Sign in or create an account" value={mode} onChange={(v) => { setMode(v as "in" | "up"); setErr(null); }}
|
||||
options={[{ value: "in", label: "Sign in" }, { value: "up", label: "Create account" }]} />
|
||||
</div>
|
||||
<form className="form" onSubmit={submit}>
|
||||
{mode === "up" ? <label>Name<input value={name} onChange={(e) => setName(e.target.value)} autoComplete="name" /></label> : null}
|
||||
<label>Email<input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" /></label>
|
||||
<label>Password<input type="password" required minLength={8} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete={mode === "in" ? "current-password" : "new-password"} /></label>
|
||||
{err ? <p className="err" role="alert">{err}</p> : null}
|
||||
<div><Pill type="submit" variant="primary" disabled={busy}>{mode === "in" ? "Sign in" : "Create account"}</Pill></div>
|
||||
{mode === "up" ? <p className="muted" style={{ fontSize: 12.5, margin: 0 }}>The first account on a new server becomes its owner.</p> : null}
|
||||
</form>
|
||||
{prov && (prov.providers.length || prov.sso) ? (
|
||||
<>
|
||||
<div className="or">or</div>
|
||||
<div className="prov">
|
||||
{prov.providers.map((p) => (
|
||||
<Pill key={p.id} onClick={() => void (p.kind === "oidc" ? redirect("/api/auth/sign-in/oauth2", { providerId: p.id }) : redirect("/api/auth/sign-in/social", { provider: p.id }))}>
|
||||
Continue with {p.label}
|
||||
</Pill>
|
||||
))}
|
||||
</div>
|
||||
{prov.sso ? (
|
||||
<form className="form" style={{ marginTop: 18 }} onSubmit={(e) => { e.preventDefault(); void redirect("/api/auth/sign-in/sso", { email: ssoEmail }); }}>
|
||||
<label>Work email for single sign-on<input type="email" required value={ssoEmail} onChange={(e) => setSsoEmail(e.target.value)} /></label>
|
||||
<div><Pill type="submit">Continue with single sign-on</Pill></div>
|
||||
</form>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Pill } from "@stc/ui";
|
||||
import type { Role } from "@stc/contracts";
|
||||
import { api, ensureStyles, refreshLibrary, toast, useApi, useLibrary, useMe } from "../api";
|
||||
import { HeaderExtras } from "../shell/HeaderExtras";
|
||||
|
||||
interface AdminUser { id: string; email: string; name: string; createdAt: string; roles: Role[]; groups: string[] }
|
||||
interface AdminGroup { id: string; name: string; members: string[] }
|
||||
interface Collection { id: string; title: string; sort: number; courseIds: string[] }
|
||||
interface Access { visibility: string; acl: { principal_type: "user" | "group"; principal_id: string }[]; authors: string[] }
|
||||
|
||||
const ROLES: Role[] = ["owner", "author", "learner"];
|
||||
|
||||
/** Owner administration: roles, groups, course access and library curation. */
|
||||
export default function AdminPage() {
|
||||
ensureStyles();
|
||||
const { loaded, user } = useMe();
|
||||
useEffect(() => { document.title = "Administration – STCourses"; }, []);
|
||||
const owner = !!user?.roles.includes("owner");
|
||||
return (
|
||||
<div className="lr" data-look="work">
|
||||
<HeaderExtras course={null} lookMode={null} />
|
||||
<article style={{ maxWidth: 900, margin: "0 auto", paddingBottom: 80 }}>
|
||||
<p className="chap"><Link to="/account">Account</Link></p>
|
||||
<h1 className="lib-h">Users, groups and library</h1>
|
||||
{!loaded ? null : !owner ? <p className="lib-sub">Only the owner of this server can open this page.</p> : <AdminSections />}
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminSections() {
|
||||
const users = useApi<AdminUser[]>("/api/admin/users");
|
||||
const groups = useApi<AdminGroup[]>("/api/admin/groups");
|
||||
return (
|
||||
<>
|
||||
<p className="lib-sub">Sign-in providers are configured on the server with STC_AUTH_* settings.</p>
|
||||
<UsersSection users={users.data ?? []} reload={users.reload} />
|
||||
<GroupsSection groups={groups.data ?? []} users={users.data ?? []} reload={() => { groups.reload(); users.reload(); }} />
|
||||
<LibrarySection />
|
||||
<AccessSection users={users.data ?? []} groups={groups.data ?? []} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersSection({ users, reload }: { users: AdminUser[]; reload: () => void }) {
|
||||
const setRoles = async (u: AdminUser, role: Role, on: boolean) => {
|
||||
const roles = on ? [...u.roles, role] : u.roles.filter((r) => r !== role);
|
||||
try { await api(`/api/admin/users/${encodeURIComponent(u.id)}/roles`, { method: "PUT", body: { roles } }); reload(); }
|
||||
catch (e) { toast((e as Error).message); }
|
||||
};
|
||||
return (
|
||||
<section className="admin-sec" aria-labelledby="adm-users">
|
||||
<h2 id="adm-users">Users and roles</h2>
|
||||
<table className="table">
|
||||
<thead><tr><th>User</th><th>Roles</th><th>Groups</th></tr></thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td><div>{u.name || u.email}</div><span className="ls">{u.email}</span></td>
|
||||
<td><div className="checks">{ROLES.map((r) => (
|
||||
<label key={r}><input type="checkbox" checked={u.roles.includes(r)} onChange={(e) => setRoles(u, r, e.target.checked)} />{r}</label>
|
||||
))}</div></td>
|
||||
<td><span className="ls">{u.groups.join(", ")}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupsSection({ groups, users, reload }: { groups: AdminGroup[]; users: AdminUser[]; reload: () => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const create = async () => {
|
||||
try { await api("/api/admin/groups", { method: "POST", body: { name } }); setName(""); reload(); } catch (e) { toast((e as Error).message); }
|
||||
};
|
||||
const setMembers = async (g: AdminGroup, userIds: string[]) => {
|
||||
try { await api(`/api/admin/groups/${encodeURIComponent(g.id)}/members`, { method: "PUT", body: { userIds } }); reload(); } catch (e) { toast((e as Error).message); }
|
||||
};
|
||||
const remove = async (g: AdminGroup) => {
|
||||
if (!confirm(`Delete the group “${g.name}”? Courses shared with it lose that access.`)) return;
|
||||
try { await api(`/api/admin/groups/${encodeURIComponent(g.id)}`, { method: "DELETE" }); reload(); } catch (e) { toast((e as Error).message); }
|
||||
};
|
||||
return (
|
||||
<section className="admin-sec" aria-labelledby="adm-groups">
|
||||
<h2 id="adm-groups">Groups</h2>
|
||||
<p className="source" style={{ marginTop: 0 }}>Groups give access to restricted courses. Groups named in OIDC or SAML claims are filled at sign-in.</p>
|
||||
{groups.length ? (
|
||||
<table className="table">
|
||||
<thead><tr><th>Group</th><th>Members</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{groups.map((g) => (
|
||||
<tr key={g.id}>
|
||||
<td>{g.name}</td>
|
||||
<td><div className="checks">{users.map((u) => (
|
||||
<label key={u.id}><input type="checkbox" checked={g.members.includes(u.id)}
|
||||
onChange={(e) => setMembers(g, e.target.checked ? [...g.members, u.id] : g.members.filter((x) => x !== u.id))} />{u.name || u.email}</label>
|
||||
))}</div></td>
|
||||
<td style={{ textAlign: "right" }}><Pill className="sm" variant="ghost" onClick={() => remove(g)}>Delete</Pill></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : null}
|
||||
<div className="row-form">
|
||||
<input className="field" placeholder="New group name" value={name} onChange={(e) => setName(e.target.value)} aria-label="New group name" />
|
||||
<Pill className="sm" disabled={!name.trim()} onClick={create}>Add group</Pill>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LibrarySection() {
|
||||
const lib = useLibrary();
|
||||
const cur = useApi<{ featured: string | null; collections: Collection[] }>("/api/admin/library");
|
||||
const [featured, setFeatured] = useState<string>("");
|
||||
const [cols, setCols] = useState<Collection[]>([]);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!cur.data) return;
|
||||
setFeatured(cur.data.featured ?? ""); setCols(cur.data.collections); setDirty(false);
|
||||
}, [cur.data]);
|
||||
const courses = lib?.courses ?? [];
|
||||
const save = async () => {
|
||||
try {
|
||||
await api("/api/admin/library", { method: "PUT", body: { featured: featured || null, collections: cols.filter((c) => c.title.trim()).map((c, i) => ({ ...c, sort: i })) } });
|
||||
cur.reload(); void refreshLibrary(); toast("Library saved");
|
||||
} catch (e) { toast((e as Error).message); }
|
||||
};
|
||||
const edit = (i: number, c: Partial<Collection>) => { setCols(cols.map((x, k) => (k === i ? { ...x, ...c } : x))); setDirty(true); };
|
||||
return (
|
||||
<section className="admin-sec" aria-labelledby="adm-lib">
|
||||
<h2 id="adm-lib">Library</h2>
|
||||
<div className="row-form" style={{ marginTop: 0 }}>
|
||||
<label className="ls" htmlFor="adm-feat">Featured course</label>
|
||||
<select id="adm-feat" className="field" value={featured} onChange={(e) => { setFeatured(e.target.value); setDirty(true); }}>
|
||||
<option value="">Automatic (what you were last working on, else the newest)</option>
|
||||
{courses.map((c) => <option key={c.id} value={c.id}>{c.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<h3 className="sec-h" style={{ marginTop: 24 }}>Collections</h3>
|
||||
<p className="source" style={{ marginTop: 0 }}>Each collection is a row in the library. A course can be in several. Courses can also name collections in their manifest.</p>
|
||||
<table className="table">
|
||||
<tbody>
|
||||
{cols.map((c, i) => (
|
||||
<tr key={c.id || `new-${i}`}>
|
||||
<td style={{ width: 220 }}><input className="field" value={c.title} aria-label="Collection title" onChange={(e) => edit(i, { title: e.target.value })} /></td>
|
||||
<td><div className="checks">{courses.map((co) => (
|
||||
<label key={co.id}><input type="checkbox" checked={c.courseIds.includes(co.id)}
|
||||
onChange={(e) => edit(i, { courseIds: e.target.checked ? [...c.courseIds, co.id] : c.courseIds.filter((x) => x !== co.id) })} />{co.title}</label>
|
||||
))}</div></td>
|
||||
<td style={{ textAlign: "right" }}><Pill className="sm" variant="ghost" onClick={() => { setCols(cols.filter((_, k) => k !== i)); setDirty(true); }}>Remove</Pill></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="row-form">
|
||||
<Pill className="sm" onClick={() => { setCols([...cols, { id: "", title: "", sort: cols.length, courseIds: [] }]); setDirty(true); }}>Add collection</Pill>
|
||||
<Pill className="sm" variant="primary" disabled={!dirty} onClick={save}>Save library</Pill>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AccessSection({ users, groups }: { users: AdminUser[]; groups: AdminGroup[] }) {
|
||||
const lib = useLibrary();
|
||||
const [courseId, setCourseId] = useState("");
|
||||
const access = useApi<Access>(courseId ? `/api/admin/courses/${encodeURIComponent(courseId)}/access` : null);
|
||||
const [draft, setDraft] = useState<Access | null>(null);
|
||||
useEffect(() => { setDraft(access.data); }, [access.data]);
|
||||
const has = (t: "user" | "group", id: string) => !!draft?.acl.some((a) => a.principal_type === t && a.principal_id === id);
|
||||
const toggle = (t: "user" | "group", id: string, on: boolean) => draft && setDraft({
|
||||
...draft, acl: on ? [...draft.acl, { principal_type: t, principal_id: id }] : draft.acl.filter((a) => !(a.principal_type === t && a.principal_id === id)),
|
||||
});
|
||||
const save = async () => {
|
||||
if (!draft) return;
|
||||
try { await api(`/api/admin/courses/${encodeURIComponent(courseId)}/access`, { method: "PUT", body: { acl: draft.acl, authors: draft.authors } }); access.reload(); toast("Access saved"); }
|
||||
catch (e) { toast((e as Error).message); }
|
||||
};
|
||||
return (
|
||||
<section className="admin-sec" aria-labelledby="adm-access">
|
||||
<h2 id="adm-access">Course access</h2>
|
||||
<p className="source" style={{ marginTop: 0 }}>Restricted courses are visible to the users and groups listed here. Authors can edit a course in the studio.</p>
|
||||
<div className="row-form" style={{ marginTop: 0 }}>
|
||||
<select className="field" value={courseId} onChange={(e) => setCourseId(e.target.value)} aria-label="Course">
|
||||
<option value="">Choose a course</option>
|
||||
{(lib?.courses ?? []).map((c) => <option key={c.id} value={c.id}>{c.title}</option>)}
|
||||
</select>
|
||||
{draft ? <span className="ls">Visibility in the manifest: {draft.visibility}</span> : null}
|
||||
</div>
|
||||
{draft ? (
|
||||
<>
|
||||
<table className="table" style={{ marginTop: 14 }}>
|
||||
<tbody>
|
||||
<tr><td style={{ width: 120 }} className="ls">Users</td><td><div className="checks">{users.map((u) => <label key={u.id}><input type="checkbox" checked={has("user", u.id)} onChange={(e) => toggle("user", u.id, e.target.checked)} />{u.name || u.email}</label>)}</div></td></tr>
|
||||
<tr><td className="ls">Groups</td><td><div className="checks">{groups.length ? groups.map((g) => <label key={g.id}><input type="checkbox" checked={has("group", g.id)} onChange={(e) => toggle("group", g.id, e.target.checked)} />{g.name}</label>) : <span className="ls">No groups yet</span>}</div></td></tr>
|
||||
<tr><td className="ls">Authors</td><td><div className="checks">{users.map((u) => <label key={u.id}><input type="checkbox" checked={draft.authors.includes(u.id)} onChange={(e) => setDraft({ ...draft, authors: e.target.checked ? [...draft.authors, u.id] : draft.authors.filter((x) => x !== u.id) })} />{u.name || u.email}</label>)}</div></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="row-form"><Pill className="sm" variant="primary" onClick={save}>Save access</Pill></div>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import type { AuthUser } from "@stc/contracts";
|
||||
import type { CoursePayload, LibraryPayload } from "../shared/types";
|
||||
import { learnerCss } from "./styles";
|
||||
|
||||
/** Injects the learner stylesheet once (the module graph stays importable on the server). */
|
||||
export function ensureStyles(): void {
|
||||
if (typeof document === "undefined" || document.getElementById("stc-learner-css")) return;
|
||||
const el = document.createElement("style");
|
||||
el.id = "stc-learner-css";
|
||||
el.textContent = learnerCss;
|
||||
document.head.append(el);
|
||||
}
|
||||
export { learnerCss };
|
||||
|
||||
export class HttpError extends Error {
|
||||
constructor(public status: number, message: string) { super(message); }
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
|
||||
const r = await fetch(path, {
|
||||
method: init.method ?? "GET",
|
||||
credentials: "same-origin",
|
||||
headers: init.body !== undefined ? { "content-type": "application/json" } : undefined,
|
||||
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
|
||||
});
|
||||
const text = await r.text();
|
||||
let data: unknown = null;
|
||||
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
||||
if (!r.ok) {
|
||||
const msg = (data && typeof data === "object" && ("error" in data || "message" in data))
|
||||
? String((data as { error?: string; message?: string }).error ?? (data as { message?: string }).message)
|
||||
: r.status === 404 ? "Not found" : `Request failed (${r.status})`;
|
||||
throw new HttpError(r.status, msg);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
/** Loads `path` (null skips). `reload()` refetches without clearing the current data. */
|
||||
export function useApi<T>(path: string | null): { data: T | null; error: HttpError | null; loading: boolean; reload: () => void; set: (v: T) => void } {
|
||||
const [state, setState] = useState<{ path: string | null; data: T | null; error: HttpError | null; loading: boolean }>({ path, data: null, error: null, loading: !!path });
|
||||
const [n, setN] = useState(0);
|
||||
const current = state.path === path ? state : { path, data: null, error: null, loading: !!path };
|
||||
useEffect(() => {
|
||||
if (!path) return;
|
||||
let live = true;
|
||||
setState((s) => (s.path === path ? { ...s, loading: true } : { path, data: null, error: null, loading: true }));
|
||||
api<T>(path).then(
|
||||
(data) => live && setState({ path, data, error: null, loading: false }),
|
||||
(e: unknown) => live && setState({ path, data: null, error: e instanceof HttpError ? e : new HttpError(0, String(e)), loading: false }),
|
||||
);
|
||||
return () => { live = false; };
|
||||
}, [path, n]);
|
||||
const reload = useCallback(() => setN((x) => x + 1), []);
|
||||
const set = useCallback((v: T) => setState((s) => ({ ...s, data: v })), []);
|
||||
return { data: current.data, error: current.error, loading: current.loading, reload, set };
|
||||
}
|
||||
|
||||
/* ---------- tiny shared stores ---------- */
|
||||
|
||||
function store<T>(initial: T) {
|
||||
let value = initial;
|
||||
const subs = new Set<() => void>();
|
||||
return {
|
||||
get: () => value,
|
||||
set(v: T) { if (v !== value) { value = v; subs.forEach((f) => f()); } },
|
||||
subscribe(f: () => void) { subs.add(f); return () => { subs.delete(f); }; },
|
||||
};
|
||||
}
|
||||
function useStore<T>(s: ReturnType<typeof store<T>>): T {
|
||||
return useSyncExternalStore(s.subscribe, s.get, s.get);
|
||||
}
|
||||
|
||||
const ls = {
|
||||
get(k: string): string | null { try { return localStorage.getItem(k); } catch { return null; } },
|
||||
set(k: string, v: string) { try { localStorage.setItem(k, v); } catch { /* private mode */ } },
|
||||
};
|
||||
|
||||
export type Look = "reading" | "work";
|
||||
const lookStore = store<Look>((typeof window !== "undefined" && ls.get("stc.look") === "work") ? "work" : "reading");
|
||||
export function useLook(): [Look, (l: Look) => void] {
|
||||
const v = useStore(lookStore);
|
||||
return [v, (l) => { ls.set("stc.look", l); lookStore.set(l); }];
|
||||
}
|
||||
|
||||
const searchStore = store("");
|
||||
export function useSearch(): [string, (q: string) => void] {
|
||||
return [useStore(searchStore), searchStore.set];
|
||||
}
|
||||
|
||||
const trackStore = store(0);
|
||||
export function useTrack(courseId: string, tracks: { id: string }[]): [string | null, (t: string) => void] {
|
||||
useStore(trackStore);
|
||||
const saved = typeof window !== "undefined" ? ls.get(`stc.track.${courseId}`) : null;
|
||||
const value = tracks.find((t) => t.id === saved)?.id ?? tracks[0]?.id ?? null;
|
||||
return [value, (t) => { ls.set(`stc.track.${courseId}`, t); trackStore.set(trackStore.get() + 1); }];
|
||||
}
|
||||
|
||||
/* ---------- session and caches ---------- */
|
||||
|
||||
const meStore = store<{ loaded: boolean; user: AuthUser | null }>({ loaded: false, user: null });
|
||||
let meLoading: Promise<void> | null = null;
|
||||
export function refreshMe(): Promise<void> {
|
||||
meLoading = api<{ user: AuthUser | null }>("/api/me").then(
|
||||
(r) => meStore.set({ loaded: true, user: r.user }),
|
||||
() => meStore.set({ loaded: true, user: null }),
|
||||
);
|
||||
return meLoading;
|
||||
}
|
||||
export function useMe(): { loaded: boolean; user: AuthUser | null } {
|
||||
const v = useStore(meStore);
|
||||
useEffect(() => { if (!v.loaded && !meLoading) void refreshMe(); }, [v.loaded]);
|
||||
return v;
|
||||
}
|
||||
|
||||
const libraryStore = store<LibraryPayload | null>(null);
|
||||
let libraryLoading: Promise<void> | null = null;
|
||||
export function refreshLibrary(): Promise<void> {
|
||||
libraryLoading = api<LibraryPayload>("/api/library").then((l) => libraryStore.set(l), () => { /* keep stale */ });
|
||||
return libraryLoading;
|
||||
}
|
||||
export function useLibrary(): LibraryPayload | null {
|
||||
const v = useStore(libraryStore);
|
||||
useEffect(() => { if (!libraryLoading) void refreshLibrary(); }, []);
|
||||
return v;
|
||||
}
|
||||
|
||||
const courseCache = new Map<string, CoursePayload>();
|
||||
const courseVersion = store(0);
|
||||
/** The course outline with progress. Cached across route changes; `reload` refetches. */
|
||||
export function useCourse(courseId: string): { course: CoursePayload | null; error: HttpError | null; reload: () => Promise<void> } {
|
||||
useStore(courseVersion);
|
||||
const [error, setError] = useState<HttpError | null>(null);
|
||||
const live = useRef(true);
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const c = await api<CoursePayload>(`/api/courses/${encodeURIComponent(courseId)}`);
|
||||
courseCache.set(courseId, c);
|
||||
if (live.current) setError(null);
|
||||
courseVersion.set(courseVersion.get() + 1);
|
||||
} catch (e) {
|
||||
if (live.current) setError(e instanceof HttpError ? e : new HttpError(0, String(e)));
|
||||
}
|
||||
}, [courseId]);
|
||||
useEffect(() => {
|
||||
live.current = true;
|
||||
setError(null);
|
||||
void reload();
|
||||
return () => { live.current = false; };
|
||||
}, [reload]);
|
||||
return { course: courseCache.get(courseId) ?? null, error, reload };
|
||||
}
|
||||
export function clearCourseCache(): void { courseCache.clear(); courseVersion.set(courseVersion.get() + 1); }
|
||||
|
||||
/* ---------- toast ---------- */
|
||||
|
||||
const toastStore = store<string | null>(null);
|
||||
let toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
export function toast(msg: string): void {
|
||||
toastStore.set(msg);
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => toastStore.set(null), 2600);
|
||||
}
|
||||
export function useToast(): string | null { return useStore(toastStore); }
|
||||
|
||||
/* ---------- formatting ---------- */
|
||||
|
||||
export function fmtTime(s: number): string {
|
||||
const t = Math.max(0, Math.floor(s));
|
||||
const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), sec = t % 60;
|
||||
return h ? `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}` : `${m}:${String(sec).padStart(2, "0")}`;
|
||||
}
|
||||
export function fmtMinutes(min: number | null | undefined): string {
|
||||
if (!min) return "";
|
||||
if (min < 60) return `${Math.round(min)} min`;
|
||||
const h = Math.floor(min / 60), m = Math.round(min % 60);
|
||||
return m ? `${h} h ${m} min` : `${h} h`;
|
||||
}
|
||||
export function fmtSize(n: number | null | undefined): string {
|
||||
if (n === null || n === undefined) return "";
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
export const lessonHref = (courseId: string, lessonId: string) => `/c/${encodeURIComponent(courseId)}/${encodeURIComponent(lessonId)}`;
|
||||
export const courseHref = (courseId: string) => `/c/${encodeURIComponent(courseId)}`;
|
||||
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
try { await navigator.clipboard.writeText(text); return true; } catch {
|
||||
const t = document.createElement("textarea");
|
||||
t.value = text; document.body.append(t); t.select();
|
||||
let ok = false;
|
||||
try { ok = document.execCommand("copy"); } catch { /* unsupported */ }
|
||||
t.remove();
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useCallback, useEffect, useMemo, type CSSProperties } from "react";
|
||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { CourseCover, FloatingActionBar, Pill, ProgressBar } from "@stc/ui";
|
||||
import type { CoursePayload, LessonPayload, Note, ProgressState } from "../../shared/types";
|
||||
import { api, ensureStyles, fmtMinutes, fmtTime, lessonHref, refreshLibrary, toast, useApi, useCourse, useLook, useMe, useTrack } from "../api";
|
||||
import { Check, Next, Prev } from "../icons";
|
||||
import { CourseFrame, courseDone, moduleNumber } from "../shell/CourseFrame";
|
||||
import { HeaderExtras } from "../shell/HeaderExtras";
|
||||
import { LessonView, TitleText } from "../lesson/LessonView";
|
||||
import { NoteItem } from "../lesson/notes";
|
||||
|
||||
function Problem({ title, children }: { title: string; children?: React.ReactNode }) {
|
||||
ensureStyles();
|
||||
return (
|
||||
<div className="lr" data-look="work">
|
||||
<HeaderExtras course={null} lookMode={null} />
|
||||
<article className="col" style={{ maxWidth: 660, margin: "0 auto" }}>
|
||||
<h1 className="lib-h">{title}</h1>
|
||||
<div className="lib-sub">{children}</div>
|
||||
<Link className="stc-pill" to="/">Back to the library</Link>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CourseMissing({ status, signedIn }: { status: number; signedIn: boolean }) {
|
||||
if (status === 404) {
|
||||
return (
|
||||
<Problem title="Course not found">
|
||||
This course does not exist, or you do not have access to it.{signedIn ? "" : <> <Link to="/account">Signing in</Link> may help.</>}
|
||||
</Problem>
|
||||
);
|
||||
}
|
||||
return <Problem title="Something went wrong">The course could not be loaded. Try again in a moment.</Problem>;
|
||||
}
|
||||
|
||||
/** First lesson that is not done (in an unlocked module), or the first lesson. */
|
||||
export function nextLesson(c: CoursePayload): { id: string; title: string } | null {
|
||||
for (const m of c.modules) {
|
||||
if (m.locked) break;
|
||||
for (const l of m.lessons) if (c.progress[l.id]?.state !== "done") return l;
|
||||
}
|
||||
return c.modules[0]?.lessons[0] ?? null;
|
||||
}
|
||||
|
||||
/* ---------- /c/:courseId ---------- */
|
||||
|
||||
export function CoursePage() {
|
||||
const { courseId = "" } = useParams();
|
||||
const { course, error } = useCourse(courseId);
|
||||
const { user } = useMe();
|
||||
const [look] = useLook();
|
||||
useEffect(() => { if (course) document.title = `${course.title} – STCourses`; }, [course]);
|
||||
if (error && !course) return <CourseMissing status={error.status} signedIn={!!user} />;
|
||||
if (!course) return null;
|
||||
const { done, total } = courseDone(course);
|
||||
const next = nextLesson(course);
|
||||
const started = Object.keys(course.progress).length > 0;
|
||||
const minutes = course.modules.flatMap((m) => m.lessons).reduce((s, l) => s + (l.minutes ?? 0), 0);
|
||||
return (
|
||||
<CourseFrame course={course} currentLessonId={null} look={look} lookMode={null}>
|
||||
<article className="col" data-look="work" style={{ paddingBottom: 60 }}>
|
||||
<div style={{ display: "flex", gap: 24, alignItems: "flex-end" }}>
|
||||
<span style={{ "--cw": "120px" } as CSSProperties}><CourseCover title={course.title} image={course.coverUrl ?? undefined} accent={course.accent ?? undefined} /></span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h1 className="title" style={{ fontFamily: "var(--sans)" }}>{course.title}</h1>
|
||||
{course.authors.length ? <p className="source">{course.authors.join(", ")}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
{course.pitch ? <p style={{ fontSize: 16, color: "var(--muted)", margin: "22px 0 0" }}>{course.pitch}</p> : null}
|
||||
<dl className="meta">
|
||||
<div><dt>Lessons</dt><dd className="tnum">{total}</dd></div>
|
||||
{minutes ? <div><dt>Time</dt><dd>About {fmtMinutes(minutes)}</dd></div> : null}
|
||||
{course.tracks.length > 1 ? <div><dt>Tracks</dt><dd>{course.tracks.map((t) => t.label).join(", ")}</dd></div> : null}
|
||||
{course.gating === "sequential" ? <div><dt>Order</dt><dd>Each module opens when the one before it is done</dd></div> : null}
|
||||
{course.signedIn ? <div style={{ minWidth: 180 }}><dt>Progress</dt><dd className="lr-prog" style={{ marginTop: 4 }}><ProgressBar value={total ? done / total : 0} label="Course progress" /><span className="tnum">{done} of {total}</span></dd></div> : null}
|
||||
</dl>
|
||||
{course.status === "error" ? <div className="notice"><strong>This course has problems.</strong> {course.canEdit ? "The list in the sidebar names them." : "Some lessons may not open."}</div> : null}
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 24, alignItems: "center", flexWrap: "wrap" }}>
|
||||
{next ? <Link className="stc-pill stc-pill--primary" to={lessonHref(course.id, next.id)}>{started ? "Continue" : "Start"}{started ? <span style={{ fontWeight: 400 }}>: {next.title.replace(/`/g, "")}</span> : null}</Link> : null}
|
||||
{!course.signedIn ? <span className="source" style={{ margin: 0 }}><Link to="/account">Sign in</Link> to keep your progress and notes.</span> : null}
|
||||
</div>
|
||||
<h2 className="sec-h" style={{ marginTop: 40 }}>Modules</h2>
|
||||
<div className="lib">
|
||||
{course.modules.map((m) => {
|
||||
const d = m.lessons.filter((l) => course.progress[l.id]?.state === "done").length;
|
||||
return (
|
||||
<Link key={m.id} className="lib-row" to={lessonHref(course.id, m.lessons[0]?.id ?? "")} style={{ gridTemplateColumns: "32px minmax(0,1fr) 120px" }}>
|
||||
<span className="lnum tnum">{moduleNumber(course, m.index)}</span>
|
||||
<span style={{ minWidth: 0 }}><span className="lt">{m.title}</span><span className="ls">{m.lessons.length === 1 ? "1 lesson" : `${m.lessons.length} lessons`}{m.locked ? " · locked" : ""}</span></span>
|
||||
{course.signedIn ? <span className="lr-prog" style={{ margin: 0 }}><ProgressBar value={m.lessons.length ? d / m.lessons.length : 0} dim label={`${m.title} progress`} /></span> : <span />}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
</CourseFrame>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- /c/:courseId/:lessonId ---------- */
|
||||
|
||||
export function LessonPage() {
|
||||
const { courseId = "", lessonId = "" } = useParams();
|
||||
const loc = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { course, error: courseError, reload } = useCourse(courseId);
|
||||
const lessonApi = useApi<LessonPayload>(`/api/courses/${encodeURIComponent(courseId)}/lessons/${encodeURIComponent(lessonId)}`);
|
||||
const lesson = lessonApi.data;
|
||||
const { user } = useMe();
|
||||
const [look] = useLook();
|
||||
const [track, setTrack] = useTrack(courseId, course?.tracks ?? lesson?.tracks ?? []);
|
||||
const q = useMemo(() => new URLSearchParams(loc.search), [loc.search]);
|
||||
const initialTime = q.has("t") ? Number(q.get("t")) : null;
|
||||
const initialSlide = q.has("slide") && q.has("deck") ? { deckId: q.get("deck")!, page: Number(q.get("slide")) } : null;
|
||||
const progress = course?.progress[lessonId] ?? null;
|
||||
const signedIn = !!user;
|
||||
|
||||
useEffect(() => { if (lesson) document.title = `${lesson.lesson.title.replace(/`/g, "")} – ${lesson.courseTitle}`; }, [lesson]);
|
||||
useEffect(() => { window.scrollTo(0, 0); }, [lessonId]);
|
||||
|
||||
const setState = useCallback(async (state: ProgressState, positionSeconds?: number) => {
|
||||
if (!signedIn) return;
|
||||
try {
|
||||
await api(`/api/courses/${encodeURIComponent(courseId)}/progress/${encodeURIComponent(lessonId)}`, { method: "PUT", body: { state, positionSeconds } });
|
||||
if (state === "done" || progress?.state !== state) { await reload(); void refreshLibrary(); }
|
||||
} catch (e) { toast(`Progress was not saved: ${(e as Error).message}`); }
|
||||
}, [courseId, lessonId, signedIn, progress?.state, reload]);
|
||||
|
||||
if (courseError && !course) return <CourseMissing status={courseError.status} signedIn={signedIn} />;
|
||||
if (lessonApi.error && course) {
|
||||
return (
|
||||
<CourseFrame course={course} currentLessonId={lessonId} look={look} lookMode={null}>
|
||||
<article className="col"><h1 className="title">Lesson not found</h1><p className="source">This course has no lesson “{lessonId}”.</p></article>
|
||||
</CourseFrame>
|
||||
);
|
||||
}
|
||||
if (!course || !lesson || lesson.lesson.id !== lessonId) {
|
||||
return course ? <CourseFrame course={course} currentLessonId={lessonId} look={look} lookMode={null}><div className="col" /></CourseFrame> : null;
|
||||
}
|
||||
const isDone = progress?.state === "done";
|
||||
const isExercise = lesson.lesson.type === "exercise";
|
||||
const n = moduleNumber(course, lesson.module.index);
|
||||
return (
|
||||
<CourseFrame course={course} currentLessonId={lessonId} look={look} lookMode={isExercise ? "work" : "toggle"}>
|
||||
<LessonView
|
||||
lesson={lesson} look={look} track={track} onTrackChange={setTrack} progress={progress} signedIn={signedIn}
|
||||
onProgress={setState} onSaved={() => { void reload(); void refreshLibrary(); }}
|
||||
initialTime={initialTime} initialSlide={initialSlide}
|
||||
moduleLabel={`${/^(section|part|chapter)\b/i.test(lesson.module.title) ? "" : "Module "}${n}`.trim()}
|
||||
/>
|
||||
<FloatingActionBar>
|
||||
<Pill variant="ghost" disabled={!lesson.prev} title={lesson.prev ? `Previous: ${lesson.prev.title}` : "First lesson"} onClick={() => lesson.prev && navigate(lessonHref(courseId, lesson.prev.id))}>
|
||||
<Prev /><span className="sr">Previous lesson</span>
|
||||
</Pill>
|
||||
<Pill variant="ghost" aria-pressed={isDone} disabled={!signedIn || lesson.locked} title={signedIn ? undefined : "Sign in to keep your progress"}
|
||||
onClick={() => void setState(isDone ? "not-started" : "done")}>
|
||||
<Check size={14} />{isDone ? "Done" : "Mark done"}
|
||||
</Pill>
|
||||
<span className="sep" aria-hidden="true" />
|
||||
<Pill variant="primary" disabled={!lesson.next} title={lesson.next ? `Next: ${lesson.next.title}` : "Last lesson"} onClick={() => lesson.next && navigate(lessonHref(courseId, lesson.next.id))}>
|
||||
<span>Next<span className="lbl-long"> lesson</span></span><Next />
|
||||
</Pill>
|
||||
</FloatingActionBar>
|
||||
</CourseFrame>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- /c/:courseId/notes ---------- */
|
||||
|
||||
export function NotesPage() {
|
||||
const { courseId = "" } = useParams();
|
||||
const { course, error } = useCourse(courseId);
|
||||
const { user, loaded } = useMe();
|
||||
const [look] = useLook();
|
||||
const notesApi = useApi<Note[]>(user ? `/api/courses/${encodeURIComponent(courseId)}/notes` : null);
|
||||
useEffect(() => { if (course) document.title = `Your notes – ${course.title}`; }, [course]);
|
||||
if (error && !course) return <CourseMissing status={error.status} signedIn={!!user} />;
|
||||
if (!course) return null;
|
||||
const titles = new Map(course.modules.flatMap((m) => m.lessons.map((l) => [l.id, { title: l.title, module: m.title }] as const)));
|
||||
const order = new Map(course.modules.flatMap((m) => m.lessons).map((l, i) => [l.id, i]));
|
||||
const notes = notesApi.data ?? [];
|
||||
const groups = new Map<string, Note[]>();
|
||||
for (const n of [...notes].sort((a, b) => (order.get(a.lessonId) ?? 1e9) - (order.get(b.lessonId) ?? 1e9) || anchorNum(a) - anchorNum(b) || a.createdAt.localeCompare(b.createdAt))) {
|
||||
if (!groups.has(n.lessonId)) groups.set(n.lessonId, []);
|
||||
groups.get(n.lessonId)!.push(n);
|
||||
}
|
||||
const remove = async (id: string) => {
|
||||
try {
|
||||
await api(`/api/notes/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
notesApi.set(notes.filter((n) => n.id !== id));
|
||||
} catch (e) { toast(`The note was not deleted: ${(e as Error).message}`); }
|
||||
};
|
||||
const update = async (id: string, body: string) => {
|
||||
try {
|
||||
const n = await api<Note>(`/api/notes/${encodeURIComponent(id)}`, { method: "PATCH", body: { body } });
|
||||
notesApi.set(notes.map((x) => (x.id === id ? n : x)));
|
||||
} catch (e) { toast(`The note was not saved: ${(e as Error).message}`); }
|
||||
};
|
||||
return (
|
||||
<CourseFrame course={course} currentLessonId={null} look={look} lookMode={null}>
|
||||
<article className="col" data-look="work" style={{ paddingBottom: 60 }}>
|
||||
<p className="chap">{course.title}</p>
|
||||
<h1 className="title">Your notes</h1>
|
||||
<p className="source">Notes on lessons, video moments and slides, in course order. Only you see them.</p>
|
||||
{!loaded ? null : !user ? (
|
||||
<div className="notice"><Link to="/account">Sign in</Link> to keep notes.</div>
|
||||
) : notesApi.loading && !notesApi.data ? <p className="muted">Loading…</p>
|
||||
: groups.size === 0 ? <div className="notice">No notes yet. Add them under a video, on a slide or at the end of a lesson.</div>
|
||||
: [...groups].map(([lid, ns]) => (
|
||||
<section key={lid} className="notes-group">
|
||||
<h2><Link to={lessonHref(courseId, lid)}><TitleText text={titles.get(lid)?.title ?? lid} /></Link></h2>
|
||||
{titles.get(lid) ? <p className="source" style={{ margin: 0 }}>{titles.get(lid)!.module}</p> : null}
|
||||
<ul className="note-list">
|
||||
{ns.map((n) => <NoteItem key={n.id} note={n} notes={{ update, remove }} courseId={courseId} extra={<AnchorLink note={n} />} />)}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</article>
|
||||
</CourseFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const anchorNum = (n: Note) => (n.anchorKind === "none" ? -1 : Number(n.anchorValue ?? 0));
|
||||
|
||||
function AnchorLink({ note }: { note: Note }) {
|
||||
const base = lessonHref(note.courseId, note.lessonId);
|
||||
if (note.anchorKind === "video" && note.anchorValue !== null) return <Link className="anchor" to={`${base}?t=${Math.floor(Number(note.anchorValue))}`}>Video {fmtTime(Number(note.anchorValue))}</Link>;
|
||||
if (note.anchorKind === "slide" && note.anchorValue !== null && note.deckId) return <Link className="anchor" to={`${base}?deck=${encodeURIComponent(note.deckId)}&slide=${note.anchorValue}`}>Slide {note.anchorValue}</Link>;
|
||||
return <span className="anchor">Lesson</span>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
const A = { fill: "none", stroke: "currentColor", "aria-hidden": true } as const;
|
||||
|
||||
export const Chev = () => <svg width="10" height="10" viewBox="0 0 10 10" strokeWidth="1.6" {...A}><path d="M3.5 2l3 3-3 3" /></svg>;
|
||||
export const Down = () => <svg width="10" height="10" viewBox="0 0 10 10" strokeWidth="1.5" {...A}><path d="M2 3.5l3 3 3-3" /></svg>;
|
||||
export const Check = ({ size = 12 }: { size?: number }) => <svg width={size} height={size} viewBox="0 0 16 16" strokeWidth="1.8" {...A}><path d="M3 8.5l3.2 3L13 4.5" /></svg>;
|
||||
export const Lock = () => <svg width="11" height="11" viewBox="0 0 16 16" strokeWidth="1.5" {...A}><rect x="3" y="7" width="10" height="7.5" rx="1.5" /><path d="M5.5 7V5a2.5 2.5 0 015 0v2" /></svg>;
|
||||
export const FileI = () => <svg width="13" height="13" viewBox="0 0 16 16" strokeWidth="1.3" {...A}><path d="M4 1.5h5.5L13 5v9.5H4z" /><path d="M9.5 1.5V5H13" /></svg>;
|
||||
export const DirI = () => <svg width="13" height="13" viewBox="0 0 16 16" strokeWidth="1.3" {...A}><path d="M1.5 3.5h5l1.5 1.5h6.5v8.5h-13z" /></svg>;
|
||||
export const PdfI = () => <svg width="14" height="14" viewBox="0 0 16 16" strokeWidth="1.3" {...A}><path d="M3.5 1.5h6L13 5v9.5H3.5z" /><path d="M5.5 9h5M5.5 11.5h3.5" /></svg>;
|
||||
export const SearchI = () => <svg width="14" height="14" viewBox="0 0 16 16" strokeWidth="1.5" {...A}><circle cx="7" cy="7" r="4.5" /><path d="M10.5 10.5L14 14" /></svg>;
|
||||
export const MenuI = () => <svg width="16" height="16" viewBox="0 0 16 16" strokeWidth="1.5" {...A}><path d="M2 4h12M2 8h12M2 12h8" /></svg>;
|
||||
export const Prev = () => <svg width="12" height="12" viewBox="0 0 12 12" strokeWidth="1.6" {...A}><path d="M7.5 2.5L4 6l3.5 3.5" /></svg>;
|
||||
export const Next = () => <svg width="12" height="12" viewBox="0 0 12 12" strokeWidth="1.6" {...A}><path d="M4.5 2.5L8 6 4.5 9.5" /></svg>;
|
||||
export const Full = () => <svg width="12" height="12" viewBox="0 0 12 12" strokeWidth="1.5" {...A}><path d="M1.5 4.5v-3h3M7.5 1.5h3v3M10.5 7.5v3h-3M4.5 10.5h-3v-3" /></svg>;
|
||||
export const DownloadI = () => <svg width="12" height="12" viewBox="0 0 12 12" strokeWidth="1.5" {...A}><path d="M6 1.5v6.5M3.2 5.5L6 8.2l2.8-2.7M1.5 10.5h9" /></svg>;
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { WebFeature } from "@stc/contracts";
|
||||
|
||||
export { LessonView, type LessonViewProps } from "./lesson/LessonView";
|
||||
export { ensureStyles as ensureLearnerStyles } from "./api";
|
||||
|
||||
/** Learner routes: library, course pages, lessons, notes and the account pages. */
|
||||
export const learnerWeb: WebFeature = {
|
||||
name: "learner",
|
||||
routes: [
|
||||
{ path: "/", element: () => import("./library/LibraryPage") },
|
||||
{ path: "/c/:courseId", element: () => import("./course/pages").then((m) => ({ default: m.CoursePage })) },
|
||||
{ path: "/c/:courseId/notes", element: () => import("./course/pages").then((m) => ({ default: m.NotesPage })) },
|
||||
{ path: "/c/:courseId/:lessonId", element: () => import("./course/pages").then((m) => ({ default: m.LessonPage })) },
|
||||
{ path: "/account", element: () => import("./account/AccountPage") },
|
||||
{ path: "/account/admin", element: () => import("./account/AdminPage") },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pill } from "@stc/ui";
|
||||
import type { CodeListing, FileRef } from "../../shared/types";
|
||||
import { mediaUrl } from "../../shared/types";
|
||||
import { copyText, fmtSize, useApi } from "../api";
|
||||
import { DirI, DownloadI, FileI, PdfI } from "../icons";
|
||||
|
||||
const MAX_VIEW = 400 * 1024;
|
||||
const BINARY = /\.(png|jpe?g|gif|webp|ico|pdf|zip|gz|tgz|jar|class|exe|dll|so|dylib|bin|mp4|mp3|wav|woff2?|ttf|otf|sqlite|db|pyc)$/i;
|
||||
|
||||
export const zipUrl = (courseId: string, moduleId: string) => `/api/courses/${encodeURIComponent(courseId)}/code/${encodeURIComponent(moduleId)}/zip`;
|
||||
|
||||
interface TreeNode { name: string; path: string; dirs: Map<string, TreeNode>; files: { name: string; path: string; size: number }[] }
|
||||
|
||||
export function buildTree(files: { path: string; size: number }[]): TreeNode {
|
||||
const root: TreeNode = { name: "", path: "", dirs: new Map(), files: [] };
|
||||
for (const f of files) {
|
||||
const parts = f.path.split("/");
|
||||
let node = root;
|
||||
for (const p of parts.slice(0, -1)) {
|
||||
if (!node.dirs.has(p)) node.dirs.set(p, { name: p, path: node.path ? `${node.path}/${p}` : p, dirs: new Map(), files: [] });
|
||||
node = node.dirs.get(p)!;
|
||||
}
|
||||
node.files.push({ name: parts[parts.length - 1]!, path: f.path, size: f.size });
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/** Picks the first file to show: a README or the entry point, else the first text file. */
|
||||
function firstFile(files: { path: string; size: number }[]): string | null {
|
||||
const text = files.filter((f) => !BINARY.test(f.path) && f.size <= MAX_VIEW);
|
||||
const pref = [/(^|\/)dockerfile[^/]*$/i, /(^|\/)(main|index|app)\.[^/]+$/i, /(^|\/)readme(\.md)?$/i];
|
||||
for (const r of pref) { const f = text.find((x) => r.test(x.path)); if (f) return f.path; }
|
||||
return text[0]?.path ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Companion code for a section: file tree, tabs, line numbers, copy, the zip download and the command
|
||||
* that runs it locally. Files are read through the media route, so visibility checks apply.
|
||||
*/
|
||||
export function CodeFiles({ courseId, moduleId, dir, title = "Files for this section" }: { courseId: string; moduleId: string; dir?: string; title?: string }) {
|
||||
const { data: listing, error } = useApi<CodeListing>(`/api/courses/${encodeURIComponent(courseId)}/code/${encodeURIComponent(moduleId)}`);
|
||||
const [tabs, setTabs] = useState<string[]>([]);
|
||||
const [current, setCurrent] = useState<string | null>(null);
|
||||
const [content, setContent] = useState<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
if (!listing) return;
|
||||
const f = firstFile(listing.files);
|
||||
setTabs(f ? [f] : []); setCurrent(f); setContent({});
|
||||
}, [listing]);
|
||||
useEffect(() => {
|
||||
if (!listing || !current || current in content) return;
|
||||
const f = listing.files.find((x) => x.path === current);
|
||||
if (!f) return;
|
||||
if (BINARY.test(f.path) || f.size > MAX_VIEW) { setContent((c) => ({ ...c, [current]: "" })); return; }
|
||||
let live = true;
|
||||
fetch(mediaUrl(courseId, `${listing.dir}/${f.path}`), { credentials: "same-origin" })
|
||||
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
|
||||
.then((t) => live && setContent((c) => ({ ...c, [current]: t })), () => live && setContent((c) => ({ ...c, [current]: "\u0000error" })));
|
||||
return () => { live = false; };
|
||||
}, [listing, current, content, courseId]);
|
||||
const tree = useMemo(() => buildTree(listing?.files ?? []), [listing]);
|
||||
|
||||
const folder = (listing?.dir ?? dir ?? "").split("/").filter(Boolean).pop() ?? moduleId;
|
||||
const head = (
|
||||
<div className="blockhead">
|
||||
<h2>{title}</h2>
|
||||
<a className="stc-pill sm" href={zipUrl(courseId, moduleId)} download={`${folder}.zip`}><DownloadI />Download files for this section (zip)</a>
|
||||
</div>
|
||||
);
|
||||
if (error) return null;
|
||||
if (!listing) return <section className="lr-block" aria-label={title}>{head}<p className="muted">Loading files…</p></section>;
|
||||
const open = (p: string) => { setTabs((t) => (t.includes(p) ? t : [...t.slice(-5), p])); setCurrent(p); };
|
||||
const file = listing.files.find((f) => f.path === current);
|
||||
const text = current ? content[current] : undefined;
|
||||
const lines = text && !text.startsWith("\u0000") ? text.replace(/\n$/, "").split("\n") : null;
|
||||
|
||||
return (
|
||||
<section className="lr-block" aria-label={title}>
|
||||
{head}
|
||||
{listing.files.length === 0 ? <p className="muted">This folder is empty.</p> : (
|
||||
<div className="files">
|
||||
<nav className="tree" aria-label="Files">
|
||||
<div className="dir"><DirI />{folder}</div>
|
||||
<TreeLevel node={tree} depth={1} current={current} onOpen={open} />
|
||||
{listing.truncated ? <div className="dir">Only the first {listing.files.length} files are listed.</div> : null}
|
||||
</nav>
|
||||
<div className="viewer">
|
||||
<div className="tabs" role="tablist">
|
||||
{tabs.map((t) => (
|
||||
<button key={t} type="button" role="tab" aria-selected={t === current} onClick={() => setCurrent(t)}>{t.split("/").pop()}</button>
|
||||
))}
|
||||
{lines ? <CopyButton text={text!} label="Copy" /> : null}
|
||||
</div>
|
||||
{file ? <div className="path">{folder}/{file.path} · {fmtSize(file.size)}</div> : null}
|
||||
{!file ? <p className="path">Pick a file on the left.</p>
|
||||
: text === undefined ? <p className="path">Loading…</p>
|
||||
: lines ? <pre className="code"><code>{lines.map((l, i) => <span key={i} className="ln">{l || " "}</span>)}</code></pre>
|
||||
: <p className="path">{text === "" ? "This file is not shown here (binary or large). It is in the zip." : "This file could not be loaded."} <a href={mediaUrl(courseId, `${listing.dir}/${file.path}`)} download>Download it</a></p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{listing.command ? <CommandBlock command={listing.command} intro={`From the folder you unzipped (${folder}):`} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeLevel({ node, depth, current, onOpen }: { node: TreeNode; depth: number; current: string | null; onOpen: (p: string) => void }) {
|
||||
const pad = { paddingLeft: 12 + depth * 14 };
|
||||
return (
|
||||
<>
|
||||
{[...node.dirs.values()].map((d) => (
|
||||
<div key={d.path}>
|
||||
<div className="dir" style={pad}><DirI />{d.name}</div>
|
||||
<TreeLevel node={d} depth={depth + 1} current={current} onOpen={onOpen} />
|
||||
</div>
|
||||
))}
|
||||
{node.files.map((f) => (
|
||||
<button key={f.path} type="button" style={pad} aria-current={f.path === current} onClick={() => onOpen(f.path)}><FileI />{f.name}</button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyButton({ text, label = "Copy" }: { text: string; label?: string }) {
|
||||
const [done, setDone] = useState(false);
|
||||
return (
|
||||
<Pill className="sm" variant="ghost" onClick={async () => { if (await copyText(text)) { setDone(true); setTimeout(() => setDone(false), 1400); } }}>
|
||||
{done ? "Copied" : label}
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommandBlock({ command, intro }: { command: string; intro?: string }) {
|
||||
return (
|
||||
<div style={{ marginTop: 18 }}>
|
||||
{intro ? <p className="muted" style={{ fontSize: 13, margin: "0 0 8px" }}>{intro}</p> : null}
|
||||
<div className="cmd"><pre className="code"><code>{command}</code></pre><CopyButton text={command} /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Attachments({ files, title = "Attachments", note }: { files: FileRef[]; title?: string; note?: string }) {
|
||||
if (!files.length) return null;
|
||||
return (
|
||||
<section className="lr-block" aria-label={title}>
|
||||
<div className="blockhead"><h2>{title}</h2>{note ? <span>{note}</span> : null}</div>
|
||||
<ul className="rows">
|
||||
{files.map((f) => (
|
||||
<li key={f.file}><PdfI /><a href={f.url} download={f.file.split("/").pop()}>{f.title}</a><span className="sz tnum">{fmtSize(f.size)}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Pill } from "@stc/ui";
|
||||
import type { DeckRef } from "../../shared/types";
|
||||
import { Full, Next, Prev } from "../icons";
|
||||
import type { NotesApi } from "./notes";
|
||||
|
||||
/** The pdf.js worker is served by the learner API from the installed pdfjs-dist. */
|
||||
export const PDF_WORKER_URL = "/api/learner/pdf.worker.mjs";
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: pdf.js document proxy (types are loaded lazily with the library)
|
||||
type PdfDoc = any;
|
||||
|
||||
let pdfjsPromise: Promise<typeof import("pdfjs-dist")> | null = null;
|
||||
function loadPdfjs() {
|
||||
pdfjsPromise ??= import("pdfjs-dist").then((m) => { m.GlobalWorkerOptions.workerSrc = PDF_WORKER_URL; return m; });
|
||||
return pdfjsPromise;
|
||||
}
|
||||
|
||||
/** Renders `page` of `doc` into `canvas` at `cssWidth` pixels wide (scaled for the device pixel ratio). */
|
||||
async function renderPage(doc: PdfDoc, page: number, canvas: HTMLCanvasElement, cssWidth: number, cssMaxHeight?: number) {
|
||||
const p = await doc.getPage(page);
|
||||
const base = p.getViewport({ scale: 1 });
|
||||
let scale = cssWidth / base.width;
|
||||
if (cssMaxHeight && base.height * scale > cssMaxHeight) scale = cssMaxHeight / base.height;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const vp = p.getViewport({ scale: scale * dpr });
|
||||
canvas.width = Math.floor(vp.width);
|
||||
canvas.height = Math.floor(vp.height);
|
||||
canvas.style.width = `${Math.floor(vp.width / dpr)}px`;
|
||||
canvas.style.height = `${Math.floor(vp.height / dpr)}px`;
|
||||
const task = p.render({ canvasContext: canvas.getContext("2d")!, viewport: vp, canvas });
|
||||
await task.promise;
|
||||
}
|
||||
|
||||
const isTyping = (t: EventTarget | null) => !!(t as Element | null)?.closest?.("input, textarea, select, video, audio, [contenteditable], [role='slider']");
|
||||
|
||||
/**
|
||||
* Slide deck viewer: 16:9 stage, previous/next, page counter, thumbnails, fullscreen, arrow keys (when
|
||||
* `keyboard` is set) and a note per slide. Pages are rendered in the browser with pdf.js.
|
||||
*/
|
||||
export function DeckViewer({ deck, notes, initialPage = 1, keyboard = true, title }: {
|
||||
deck: DeckRef; notes: NotesApi | null; initialPage?: number; keyboard?: boolean; title?: string;
|
||||
}) {
|
||||
const [doc, setDoc] = useState<PdfDoc | null>(null);
|
||||
const [pages, setPages] = useState(0);
|
||||
const [page, setPage] = useState(initialPage);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const stage = useRef<HTMLElement>(null);
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true, loaded: PdfDoc | null = null;
|
||||
setDoc(null); setError(null); setPages(0);
|
||||
loadPdfjs()
|
||||
.then((pdfjs) => pdfjs.getDocument({ url: deck.url, withCredentials: true }).promise)
|
||||
.then((d: PdfDoc) => {
|
||||
loaded = d;
|
||||
if (!live) { void d.destroy(); return; }
|
||||
setDoc(d); setPages(d.numPages); setPage((p) => Math.min(Math.max(1, p), d.numPages));
|
||||
})
|
||||
.catch((e: unknown) => live && setError(`This deck could not be opened. ${(e as Error).message ?? ""}`.trim()));
|
||||
return () => { live = false; if (loaded) void loaded.destroy(); };
|
||||
}, [deck.url]);
|
||||
|
||||
// Render the current page to fit the stage.
|
||||
useEffect(() => {
|
||||
if (!doc || !canvas.current || !stage.current) return;
|
||||
let live = true;
|
||||
const draw = () => {
|
||||
const r = stage.current!.getBoundingClientRect();
|
||||
if (live) renderPage(doc, page, canvas.current!, r.width, r.height).catch(() => {});
|
||||
};
|
||||
draw();
|
||||
const ro = new ResizeObserver(() => draw());
|
||||
ro.observe(stage.current);
|
||||
return () => { live = false; ro.disconnect(); };
|
||||
}, [doc, page]);
|
||||
|
||||
const go = useCallback((p: number) => setPage((cur) => (pages ? Math.min(Math.max(1, p), pages) : cur)), [pages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!keyboard) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey || e.metaKey || e.altKey || isTyping(e.target)) return;
|
||||
if (e.key === "ArrowRight") { e.preventDefault(); setPage((p) => Math.min(p + 1, pages || p)); }
|
||||
else if (e.key === "ArrowLeft") { e.preventDefault(); setPage((p) => Math.max(p - 1, 1)); }
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [keyboard, pages]);
|
||||
|
||||
useEffect(() => {
|
||||
document.querySelector(`[data-deck="${CSS.escape(deck.id)}"] .thumbs [aria-current="true"]`)?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
}, [page, deck.id]);
|
||||
|
||||
const fullscreen = () => {
|
||||
if (document.fullscreenElement) void document.exitFullscreen();
|
||||
else stage.current?.requestFullscreen?.().catch(() => {});
|
||||
};
|
||||
const slideNotes = notes?.notes.filter((n) => n.anchorKind === "slide" && n.deckId === deck.id) ?? [];
|
||||
const withNote = new Set(slideNotes.map((n) => Number(n.anchorValue)));
|
||||
|
||||
return (
|
||||
<section className="deck" data-deck={deck.id} aria-label={`Slides: ${title ?? deck.title}`}>
|
||||
<figure className="stage" ref={stage}>
|
||||
{error ? <span className="msg">{error} <a href={deck.url} target="_blank" rel="noopener">Open the PDF</a></span> : null}
|
||||
{!error && !doc ? <span className="msg">Loading slides…</span> : null}
|
||||
<canvas ref={canvas} hidden={!doc || !!error} role="img" aria-label={`Slide ${page} of ${pages}`} />
|
||||
</figure>
|
||||
<div className="deckbar">
|
||||
<Pill className="sm" aria-label="Previous slide" disabled={page <= 1} onClick={() => go(page - 1)}><Prev /></Pill>
|
||||
<span className="count tnum" aria-live="polite">{pages ? `${page} / ${pages}` : "– / –"}</span>
|
||||
<Pill className="sm" aria-label="Next slide" disabled={!pages || page >= pages} onClick={() => go(page + 1)}><Next /></Pill>
|
||||
<span className="gap" />
|
||||
{keyboard ? <span className="hint">Arrow keys change slides</span> : null}
|
||||
<a className="stc-pill stc-pill--ghost sm" href={deck.url} target="_blank" rel="noopener">PDF</a>
|
||||
<Pill className="sm" onClick={fullscreen}><Full />Fullscreen</Pill>
|
||||
</div>
|
||||
{doc ? <Thumbs doc={doc} pages={pages} page={page} onPick={go} withNote={withNote} /> : null}
|
||||
{notes ? <SlideNote notes={notes} deckId={deck.id} page={page} existing={slideNotes.filter((n) => Number(n.anchorValue) === page)} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Thumbs({ doc, pages, page, onPick, withNote }: { doc: PdfDoc; pages: number; page: number; onPick: (p: number) => void; withNote: Set<number> }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const root = ref.current;
|
||||
if (!root) return;
|
||||
const queue: HTMLCanvasElement[] = [];
|
||||
let running = false, live = true;
|
||||
const pump = async () => {
|
||||
if (running) return;
|
||||
running = true;
|
||||
while (live && queue.length) {
|
||||
const c = queue.shift()!;
|
||||
await renderPage(doc, Number(c.dataset.p), c, 104).catch(() => {});
|
||||
}
|
||||
running = false;
|
||||
};
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue;
|
||||
const c = e.target as HTMLCanvasElement;
|
||||
io.unobserve(c);
|
||||
queue.push(c);
|
||||
}
|
||||
void pump();
|
||||
}, { root, rootMargin: "0px 400px" });
|
||||
root.querySelectorAll("canvas").forEach((c) => io.observe(c));
|
||||
return () => { live = false; io.disconnect(); };
|
||||
}, [doc, pages]);
|
||||
return (
|
||||
<div className="thumbs" ref={ref} role="group" aria-label="Slides">
|
||||
{Array.from({ length: pages }, (_, i) => (
|
||||
<button key={i} type="button" aria-label={`Slide ${i + 1}${withNote.has(i + 1) ? ", has a note" : ""}`} aria-current={page === i + 1} className={withNote.has(i + 1) ? "has-note" : undefined} onClick={() => onPick(i + 1)}>
|
||||
<canvas data-p={i + 1} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlideNote({ notes, deckId, page, existing }: { notes: NotesApi; deckId: string; page: number; existing: { id: string; body: string }[] }) {
|
||||
const first = existing[0];
|
||||
const [text, setText] = useState(first?.body ?? "");
|
||||
const [saved, setSaved] = useState<"idle" | "saving" | "saved">("idle");
|
||||
const busy = useRef(false);
|
||||
useEffect(() => { setText(first?.body ?? ""); setSaved("idle"); }, [page, deckId, first?.id, first?.body]);
|
||||
if (!notes.enabled) return null;
|
||||
const dirty = text.trim() !== (first?.body ?? "").trim();
|
||||
const save = async () => {
|
||||
if (!dirty || busy.current) return;
|
||||
busy.current = true;
|
||||
setSaved("saving");
|
||||
const body = text.trim();
|
||||
try {
|
||||
if (first && !body) await notes.remove(first.id);
|
||||
else if (first) await notes.update(first.id, body);
|
||||
else if (body) await notes.create({ anchorKind: "slide", anchorValue: page, deckId, body });
|
||||
setSaved("saved");
|
||||
} finally { busy.current = false; }
|
||||
};
|
||||
return (
|
||||
<div className="slide-note">
|
||||
<label htmlFor={`sn-${deckId}`}>Your note on slide {page}</label>
|
||||
<textarea id={`sn-${deckId}`} className="note-ta" value={text} onChange={(e) => { setText(e.target.value); setSaved("idle"); }} onBlur={save}
|
||||
placeholder="Only you see this. It comes back when you return to this slide." />
|
||||
<div className="note-actions">
|
||||
<span className="msg">{saved === "saving" ? "Saving…" : saved === "saved" ? "Saved" : ""}</span>
|
||||
{existing.length > 1 ? <span className="msg">{existing.length - 1} more on this slide in your notes list</span> : null}
|
||||
<Pill className="sm" disabled={!dirty} onClick={save}>Save note</Pill>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Pill } from "@stc/ui";
|
||||
import type { ExerciseRunner, TestResult } from "@stc/contracts";
|
||||
import type { LessonPayload } from "../../shared/types";
|
||||
import { api, toast } from "../api";
|
||||
import { createBrowserRunner } from "../runner/runner";
|
||||
import { CommandBlock } from "./CodeFiles";
|
||||
import { Chev } from "../icons";
|
||||
|
||||
export const RUN_TIMEOUT_MS = 3000;
|
||||
|
||||
export function localCommandFor(cmd: LessonPayload["localCommand"], track: string | null): string | null {
|
||||
if (!cmd) return null;
|
||||
if (typeof cmd === "string") return cmd;
|
||||
return (track && cmd[track]) || Object.values(cmd)[0] || null;
|
||||
}
|
||||
|
||||
const attemptUrl = (l: LessonPayload, track: string) =>
|
||||
`/api/courses/${encodeURIComponent(l.courseId)}/lessons/${encodeURIComponent(l.lesson.id)}/attempt?track=${encodeURIComponent(track)}`;
|
||||
|
||||
/**
|
||||
* The right-hand side of an exercise page. With a browser exercise: editor, Run tests, Reset and per-test
|
||||
* results (run in a Web Worker). Without one: the local command, plus optional pasted evidence.
|
||||
*/
|
||||
export function ExercisePanel({ lesson, track, signedIn, preview, onSaved, runner }: {
|
||||
lesson: LessonPayload;
|
||||
track: string | null;
|
||||
signedIn: boolean;
|
||||
preview?: boolean;
|
||||
onSaved?: () => void;
|
||||
runner?: ExerciseRunner;
|
||||
}) {
|
||||
if (lesson.browser) return <BrowserExercise lesson={lesson} track={track} save={signedIn && !preview} onSaved={onSaved} runner={runner} />;
|
||||
return <LocalExercise lesson={lesson} track={track} save={signedIn && !preview} onSaved={onSaved} />;
|
||||
}
|
||||
|
||||
function BrowserExercise({ lesson, track, save, onSaved, runner }: {
|
||||
lesson: LessonPayload; track: string | null; save: boolean; onSaved?: () => void; runner?: ExerciseRunner;
|
||||
}) {
|
||||
const b = lesson.browser!;
|
||||
const key = b.language;
|
||||
const [code, setCode] = useState(b.starter);
|
||||
const [results, setResults] = useState<TestResult[] | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const ta = useRef<HTMLTextAreaElement>(null);
|
||||
const gutter = useRef<HTMLPreElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCode(b.starter); setResults(null);
|
||||
if (!save) return;
|
||||
let live = true;
|
||||
api<{ code: string; result: TestResult[] | null } | null>(attemptUrl(lesson, key)).then((a) => {
|
||||
if (!live || !a) return;
|
||||
setCode(a.code);
|
||||
if (Array.isArray(a.result)) setResults(a.result);
|
||||
}, () => {});
|
||||
return () => { live = false; };
|
||||
}, [lesson.courseId, lesson.lesson.id, b.starter, key, save]);
|
||||
|
||||
const run = async () => {
|
||||
setRunning(true);
|
||||
try {
|
||||
const r = runner ?? createBrowserRunner(b.language);
|
||||
const res = await r.run(code, b.tests, RUN_TIMEOUT_MS);
|
||||
setResults(res);
|
||||
const passed = res.length > 0 && res.every((t) => t.passed);
|
||||
if (save) {
|
||||
try {
|
||||
await api(attemptUrl(lesson, key).split("?")[0]!, { method: "PUT", body: { track: key, code, result: res, passed } });
|
||||
onSaved?.();
|
||||
} catch (e) { toast(`Your code was not saved: ${(e as Error).message}`); }
|
||||
}
|
||||
} finally { setRunning(false); }
|
||||
};
|
||||
const reset = () => {
|
||||
if (code !== b.starter && !confirm("Reset the editor to the starting code? Your changes are lost.")) return;
|
||||
setCode(b.starter); setResults(null); toast("Code reset to the starting code");
|
||||
};
|
||||
const lines = code.split("\n").length;
|
||||
const n = results?.filter((t) => t.passed).length ?? 0;
|
||||
const all = !!results && results.length > 0 && n === results.length;
|
||||
|
||||
return (
|
||||
<div className="edit">
|
||||
<div className="edit-bar">
|
||||
<span className="f">{b.starterFile}</span>
|
||||
<Pill className="sm" variant="ghost" onClick={reset}>Reset</Pill>
|
||||
<Pill className="sm" variant="primary" onClick={run} disabled={running}>{running ? "Running…" : "Run tests"}</Pill>
|
||||
</div>
|
||||
<div className="editor">
|
||||
<pre className="gutter" ref={gutter} aria-hidden="true">{Array.from({ length: lines }, (_, i) => i + 1).join("\n")}</pre>
|
||||
<textarea
|
||||
ref={ta} value={code} spellCheck={false} aria-label={`Code editor, ${b.starterFile}`}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
onScroll={(e) => { if (gutter.current) gutter.current.scrollTop = (e.target as HTMLTextAreaElement).scrollTop; }}
|
||||
onKeyDown={(e) => {
|
||||
// Tab indents inside the editor; Escape leaves it so Tab moves focus again.
|
||||
if (e.key === "Tab" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
const t = e.currentTarget;
|
||||
t.setRangeText(" ", t.selectionStart, t.selectionEnd, "end");
|
||||
setCode(t.value);
|
||||
} else if (e.key === "Escape") e.currentTarget.blur();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="results" aria-live="polite">
|
||||
{!results ? (
|
||||
<div className="res-sum"><span>Not run yet. The tests run in your browser, in a sandboxed worker.</span></div>
|
||||
) : (
|
||||
<>
|
||||
<div className="res-sum"><span><b className="tnum">{n} of {results.length}</b> passed{all ? ". This exercise is done." : ""}</span></div>
|
||||
<ul className="res-list">
|
||||
{results.map((t, i) => (
|
||||
<li key={i} className={t.passed ? "pass" : "fail"}>
|
||||
<div className="res-row"><span className="s">{t.passed ? "Pass" : "Fail"}</span><code>{t.name}</code></div>
|
||||
{t.passed ? null : (
|
||||
<dl className="diff">
|
||||
{t.expected !== undefined ? <><dt>Expected</dt><dd>{t.expected}</dd></> : null}
|
||||
{t.actual !== undefined ? <><dt>Actual</dt><dd>{t.actual}</dd></> : null}
|
||||
{t.message && t.expected === undefined ? <><dt>Error</dt><dd>{t.message}</dd></> : null}
|
||||
</dl>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{b.solution ? (
|
||||
<details className="fold" style={{ margin: 0, borderBottom: 0, padding: "0 14px" }}>
|
||||
<summary><Chev /><span className="sum">Solution (only authors see this)</span></summary>
|
||||
<div className="in"><pre className="code"><code>{b.solution}</code></pre></div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LocalExercise({ lesson, track, save, onSaved }: { lesson: LessonPayload; track: string | null; save: boolean; onSaved?: () => void }) {
|
||||
const cmd = localCommandFor(lesson.localCommand, track);
|
||||
const key = track ?? "default";
|
||||
const [evidence, setEvidence] = useState("");
|
||||
const [saved, setSaved] = useState(false);
|
||||
useEffect(() => {
|
||||
setEvidence(""); setSaved(false);
|
||||
if (!save) return;
|
||||
let live = true;
|
||||
api<{ code: string } | null>(attemptUrl(lesson, key)).then((a) => { if (live && a) { setEvidence(a.code); setSaved(true); } }, () => {});
|
||||
return () => { live = false; };
|
||||
}, [lesson.courseId, lesson.lesson.id, key, save]);
|
||||
const submit = async () => {
|
||||
try {
|
||||
// Pasted passing output counts as a pass: the lesson is marked done.
|
||||
await api(attemptUrl(lesson, key).split("?")[0]!, { method: "PUT", body: { track: key, code: evidence, result: { kind: "evidence" }, passed: true } });
|
||||
setSaved(true);
|
||||
onSaved?.();
|
||||
toast("Output saved. The exercise is marked done.");
|
||||
} catch (e) { toast(`Not saved: ${(e as Error).message}`); }
|
||||
};
|
||||
return (
|
||||
<div className="local-panel">
|
||||
<h2>Run it on your machine</h2>
|
||||
<p>This exercise runs locally, in the course’s own project. Run the tests from the course folder; when they pass, mark the exercise done.</p>
|
||||
{cmd ? <CommandBlock command={cmd} /> : <p>The course does not name a command for this exercise. Its instructions on the left say how to check your work.</p>}
|
||||
{save ? (
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<label className="muted" htmlFor="lr-evidence" style={{ display: "block", fontSize: 12.5, marginBottom: 6 }}>Passing output (optional). Paste it here to keep it with your progress.</label>
|
||||
<textarea id="lr-evidence" className="note-ta" value={evidence} onChange={(e) => { setEvidence(e.target.value); setSaved(false); }} placeholder="# tests 5 / # pass 5" />
|
||||
<div className="note-actions">
|
||||
<span className="msg">{saved && evidence ? "Saved" : ""}</span>
|
||||
<Pill className="sm" disabled={!evidence.trim() || saved} onClick={submit}>Save output and mark done</Pill>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { SegmentedToggle } from "@stc/ui";
|
||||
import type { ExerciseRunner } from "@stc/contracts";
|
||||
import type { LessonPayload, LessonProgress, ProgressState } from "../../shared/types";
|
||||
import { ensureStyles, fmtMinutes, fmtTime, type Look } from "../api";
|
||||
import { renderHintsHtml, renderLessonHtml } from "../markdown";
|
||||
import { Attachments, CodeFiles, CommandBlock, zipUrl } from "./CodeFiles";
|
||||
import { DeckViewer } from "./DeckViewer";
|
||||
import { ExercisePanel, localCommandFor } from "./Exercise";
|
||||
import { NotesPanel, useLessonNotes, type NotesApi } from "./notes";
|
||||
import { Prose } from "./Prose";
|
||||
import { DownloadI } from "../icons";
|
||||
import { TitleText } from "./TitleText";
|
||||
|
||||
export interface LessonViewProps {
|
||||
lesson: LessonPayload;
|
||||
/** Reading or Work. Exercise pages always use Work. */
|
||||
look?: Look;
|
||||
track?: string | null;
|
||||
onTrackChange?: (track: string) => void;
|
||||
progress?: LessonProgress | null;
|
||||
/** Signed-in learners get notes and saved attempts. */
|
||||
signedIn?: boolean;
|
||||
/** Studio preview: no notes, no saved attempts, no progress reports. */
|
||||
preview?: boolean;
|
||||
onProgress?: (state: ProgressState, positionSeconds?: number) => void;
|
||||
/** An exercise attempt was saved (the server already updated progress). */
|
||||
onSaved?: () => void;
|
||||
/** Start the video here (seconds), e.g. from a timestamp note. */
|
||||
initialTime?: number | null;
|
||||
/** Open a deck at this page, e.g. from a slide note. */
|
||||
initialSlide?: { deckId: string; page: number } | null;
|
||||
/** Replaces the Web Worker runner (tests). */
|
||||
runner?: ExerciseRunner;
|
||||
/** Label for the module line above the title, e.g. "Module 3". */
|
||||
moduleLabel?: string;
|
||||
}
|
||||
|
||||
export { TitleText };
|
||||
|
||||
/**
|
||||
* Renders one lesson of any type from its `LessonPayload`: text (track switcher, folds, hint tiers),
|
||||
* video (player, notes with timestamps, transcript, companion deck), exercise (instructions, hints,
|
||||
* browser runner or local command), plus decks, code files and attachments.
|
||||
* Presentational: it fetches only notes, saved attempts and code listings. The studio uses it for previews.
|
||||
*/
|
||||
export function LessonView(props: LessonViewProps) {
|
||||
ensureStyles();
|
||||
const { lesson, preview = false, signedIn = false } = props;
|
||||
const notes = useLessonNotes(lesson.courseId, lesson.lesson.id, signedIn && !preview && !lesson.locked);
|
||||
const look: Look = lesson.lesson.type === "exercise" ? "work" : props.look ?? "reading";
|
||||
const track = props.track ?? lesson.tracks[0]?.id ?? null;
|
||||
const chap = props.moduleLabel ? `${props.moduleLabel}: ${lesson.module.title}` : lesson.module.title;
|
||||
|
||||
let body: ReactNode;
|
||||
if (lesson.locked) body = <Locked lesson={lesson} chap={chap} />;
|
||||
else if (lesson.lesson.type === "video") body = <VideoLesson {...props} chap={chap} notes={notes} />;
|
||||
else if (lesson.lesson.type === "exercise") body = <ExerciseLesson {...props} chap={chap} track={track} />;
|
||||
else body = <TextLesson {...props} chap={chap} track={track} notes={notes} />;
|
||||
|
||||
return <div className="lr lr-lesson" data-look={look} data-type={lesson.lesson.type}>{body}</div>;
|
||||
}
|
||||
|
||||
function Locked({ lesson, chap }: { lesson: LessonPayload; chap: string }) {
|
||||
return (
|
||||
<article className="col">
|
||||
<p className="chap">{chap}</p>
|
||||
<h1 className="title"><TitleText text={lesson.lesson.title} /></h1>
|
||||
<div className="notice" role="note">
|
||||
<strong>This module is locked.</strong> It opens when every lesson in the modules before it is done.
|
||||
{lesson.prev ? <div><Link className="stc-pill" to={`/c/${encodeURIComponent(lesson.courseId)}/${encodeURIComponent(lesson.prev.id)}`}>Back to {lesson.prev.title}</Link></div> : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackSwitch({ lesson, track, onChange }: { lesson: LessonPayload; track: string | null; onChange?: (t: string) => void }) {
|
||||
if (lesson.tracks.length < 2 || !track) return null;
|
||||
return (
|
||||
<div>
|
||||
<dt>Track</dt>
|
||||
<dd><SegmentedToggle label="Track" value={track} options={lesson.tracks.map((t) => ({ value: t.id, label: t.label }))} onChange={(v) => onChange?.(v)} /></dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useLessonHtml(lesson: LessonPayload, track: string | null): string | null {
|
||||
return useMemo(() => {
|
||||
const md = (track && lesson.trackMarkdown[track]) || lesson.markdown;
|
||||
if (!md) return null;
|
||||
return renderLessonHtml(md, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles, stripFirstHeading: true });
|
||||
}, [lesson, track]);
|
||||
}
|
||||
|
||||
function Decks({ lesson, notes, initialSlide, keyboard }: { lesson: LessonPayload; notes: NotesApi | null; initialSlide?: { deckId: string; page: number } | null; keyboard: boolean }) {
|
||||
if (!lesson.decks.length) return null;
|
||||
return (
|
||||
<>
|
||||
{lesson.decks.map((d, i) => (
|
||||
<div key={d.id} className="lr-block">
|
||||
{lesson.decks.length > 1 || lesson.lesson.type !== "text" ? <div className="blockhead"><h2>{d.title}</h2><span>Slides</span></div> : null}
|
||||
<DeckViewer deck={d} notes={notes} keyboard={keyboard && i === 0} initialPage={initialSlide?.deckId === d.id ? initialSlide.page : 1} />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TextLesson(p: LessonViewProps & { chap: string; track: string | null; notes: NotesApi }) {
|
||||
const { lesson, track } = p;
|
||||
const html = useLessonHtml(lesson, track);
|
||||
const cmd = localCommandFor(lesson.localCommand, track);
|
||||
const minutes = lesson.lesson.minutes;
|
||||
const hasMeta = !!minutes || lesson.tracks.length > 1;
|
||||
const decks = <Decks lesson={lesson} notes={p.preview ? null : p.notes} initialSlide={p.initialSlide} keyboard />;
|
||||
return (
|
||||
<article className="wide">
|
||||
<div className="col">
|
||||
<p className="chap">{p.chap}</p>
|
||||
<h1 className="title"><TitleText text={lesson.lesson.title} /></h1>
|
||||
{hasMeta ? (
|
||||
<dl className="meta">
|
||||
{minutes ? <div><dt>Time</dt><dd>About {fmtMinutes(minutes)}</dd></div> : null}
|
||||
<TrackSwitch lesson={lesson} track={track} onChange={p.onTrackChange} />
|
||||
</dl>
|
||||
) : null}
|
||||
{cmd ? <CommandBlock command={cmd} intro="Start this module on your machine:" /> : null}
|
||||
</div>
|
||||
{!html ? decks : null}
|
||||
{html ? <div className="col"><Prose html={html} style={{ marginTop: 30 }} /></div> : null}
|
||||
{!html && !lesson.decks.length ? <div className="col"><p className="muted" style={{ marginTop: 30 }}>This lesson has no text yet.</p></div> : null}
|
||||
{html ? decks : null}
|
||||
{lesson.code ? <CodeFiles courseId={lesson.courseId} moduleId={lesson.code.moduleId} dir={lesson.code.dir} /> : null}
|
||||
<div className="col">
|
||||
<Attachments files={lesson.attachments} note="This section" />
|
||||
{!p.preview ? <div className="lr-block"><NotesPanel notes={p.notes} /></div> : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function VideoLesson(p: LessonViewProps & { chap: string; notes: NotesApi }) {
|
||||
const { lesson } = p;
|
||||
const v = useRef<HTMLVideoElement>(null);
|
||||
const [browse, setBrowse] = useState(false);
|
||||
const transcriptHtml = useMemo(() => (lesson.transcript
|
||||
? renderLessonHtml(lesson.transcript, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, lessonFiles: lesson.lessonFiles, stripFirstHeading: true })
|
||||
: null), [lesson]);
|
||||
const control = useMemo(() => ({
|
||||
currentTime: () => v.current?.currentTime ?? 0,
|
||||
seek: (s: number) => { const el = v.current; if (el) { el.currentTime = s; void el.play().catch(() => {}); el.scrollIntoView({ block: "nearest", behavior: "smooth" }); } },
|
||||
}), []);
|
||||
useVideoProgress(v, p);
|
||||
const dur = lesson.video?.duration ?? (lesson.lesson.minutes ? lesson.lesson.minutes * 60 : null);
|
||||
const folder = lesson.code?.dir.split("/").filter(Boolean).pop();
|
||||
return (
|
||||
<article className="wide">
|
||||
<p className="chap">{p.chap}</p>
|
||||
<h1 className="title"><TitleText text={lesson.lesson.title} /></h1>
|
||||
<p className="source"><strong>Video</strong>{dur ? `, ${fmtTime(dur)}` : ""}.{p.progress?.positionSeconds && p.progress.state !== "done" ? ` You stopped at ${fmtTime(p.progress.positionSeconds)}.` : ""}</p>
|
||||
{lesson.video ? (
|
||||
<div className="player"><video ref={v} controls preload="metadata" src={lesson.video.url} /></div>
|
||||
) : <div className="notice">The video file for this lesson is missing.</div>}
|
||||
<Decks lesson={lesson} notes={p.preview ? null : p.notes} initialSlide={p.initialSlide} keyboard />
|
||||
<div className="split">
|
||||
<div>
|
||||
{!p.preview ? <NotesPanel notes={p.notes} video={lesson.video ? control : null} /> : null}
|
||||
{transcriptHtml ? (
|
||||
<details className="fold transcript" open={!!p.preview}>
|
||||
<summary><svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true"><path d="M3.5 2l3 3-3 3" /></svg><span className="sum">Transcript</span></summary>
|
||||
<div className="in"><Prose html={transcriptHtml} style={{ fontSize: "calc(var(--ps) * .9)" }} /></div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
<aside>
|
||||
<div className="blockhead"><h2>Files for this section</h2><span>{lesson.module.title}</span></div>
|
||||
{lesson.attachments.length || lesson.code ? (
|
||||
<ul className="rows">
|
||||
{lesson.code ? (
|
||||
<li><DownloadI /><a href={zipUrl(lesson.courseId, lesson.code.moduleId)} download={`${folder}.zip`}>{folder}.zip</a><button type="button" className="stc-pill stc-pill--ghost sm" onClick={() => setBrowse(!browse)} aria-expanded={browse}>{browse ? "Hide" : "Browse"}</button></li>
|
||||
) : null}
|
||||
{lesson.attachments.map((f) => (
|
||||
<li key={f.file}><DownloadI /><a href={f.url} download={f.file.split("/").pop()}>{f.title}</a><span className="sz tnum">{f.size ? `${Math.max(1, Math.round(f.size / 1024))} KB` : ""}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
) : <p className="source" style={{ marginTop: 0 }}>This section has no files.</p>}
|
||||
</aside>
|
||||
</div>
|
||||
{browse && lesson.code ? <CodeFiles courseId={lesson.courseId} moduleId={lesson.code.moduleId} dir={lesson.code.dir} title="Code for this section" /> : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/** Resume position, periodic position reports, and "done" near the end (spec 4.4). */
|
||||
function useVideoProgress(v: React.RefObject<HTMLVideoElement | null>, p: LessonViewProps) {
|
||||
const report = useRef(p.onProgress);
|
||||
report.current = p.onProgress;
|
||||
const doneRef = useRef(p.progress?.state === "done");
|
||||
doneRef.current = p.progress?.state === "done";
|
||||
const start = p.initialTime ?? (p.progress && p.progress.state !== "done" ? p.progress.positionSeconds : null);
|
||||
useEffect(() => {
|
||||
const el = v.current;
|
||||
if (!el || p.preview) return;
|
||||
let lastSent = -1;
|
||||
const onMeta = () => { if (start && start > 0 && (!el.duration || start < el.duration - 2)) el.currentTime = start; };
|
||||
const send = () => {
|
||||
if (!el.duration || doneRef.current) return;
|
||||
const t = el.currentTime;
|
||||
if (t >= el.duration * 0.95 || el.duration - t < 10) { doneRef.current = true; report.current?.("done", t); return; }
|
||||
if (Math.abs(t - lastSent) >= 5) { lastSent = t; report.current?.("in-progress", t); }
|
||||
};
|
||||
let tick = 0;
|
||||
const onTime = () => { if (++tick % 40 === 0 || (el.duration && el.duration - el.currentTime < 10)) send(); };
|
||||
if (el.readyState >= 1) onMeta(); else el.addEventListener("loadedmetadata", onMeta, { once: true });
|
||||
el.addEventListener("pause", send);
|
||||
el.addEventListener("ended", send);
|
||||
el.addEventListener("timeupdate", onTime);
|
||||
return () => {
|
||||
el.removeEventListener("loadedmetadata", onMeta);
|
||||
el.removeEventListener("pause", send);
|
||||
el.removeEventListener("ended", send);
|
||||
el.removeEventListener("timeupdate", onTime);
|
||||
if (el.currentTime > 0 && !el.ended) send();
|
||||
};
|
||||
}, [p.lesson.courseId, p.lesson.lesson.id, p.preview, start, v]);
|
||||
}
|
||||
|
||||
function ExerciseLesson(p: LessonViewProps & { chap: string; track: string | null }) {
|
||||
const { lesson, track } = p;
|
||||
const html = useLessonHtml(lesson, track);
|
||||
const hintsHtml = useMemo(() => (lesson.hints.length
|
||||
? renderHintsHtml(lesson.hints, { courseId: lesson.courseId, baseFile: lesson.baseFile, tracks: lesson.tracks, track, lessonFiles: lesson.lessonFiles })
|
||||
: null), [lesson, track]);
|
||||
const cmd = localCommandFor(lesson.localCommand, track);
|
||||
return (
|
||||
<article className="wide">
|
||||
<div className="ex">
|
||||
<div>
|
||||
<p className="chap">{p.chap}</p>
|
||||
<h1 className="title"><TitleText text={lesson.lesson.title} /></h1>
|
||||
{lesson.tracks.length > 1 && !lesson.browser ? <dl className="meta" style={{ paddingBottom: 16 }}><TrackSwitch lesson={lesson} track={track} onChange={p.onTrackChange} /></dl> : null}
|
||||
{html ? <Prose html={html} style={{ marginTop: 20 }} /> : <p className="muted" style={{ marginTop: 20 }}>The instructions for this exercise are missing.</p>}
|
||||
{hintsHtml ? (
|
||||
<div className="hints">
|
||||
<h2 className="sec-h" style={{ marginTop: 26 }}>Hints</h2>
|
||||
<Prose html={hintsHtml} />
|
||||
</div>
|
||||
) : null}
|
||||
{lesson.browser && cmd ? (
|
||||
<p className="local"><strong>Runs in your browser; the full project runs on your machine.</strong> To run the whole module locally:<code>{cmd}</code></p>
|
||||
) : null}
|
||||
</div>
|
||||
<ExercisePanel lesson={lesson} track={track} signedIn={!!p.signedIn} preview={p.preview} runner={p.runner}
|
||||
onSaved={p.onSaved} />
|
||||
</div>
|
||||
<Attachments files={lesson.attachments} note="This section" />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useRef, type CSSProperties } from "react";
|
||||
import { useInRouterContext, useNavigate } from "react-router-dom";
|
||||
|
||||
/** Router navigation when a router is present (the studio may embed LessonView without one). */
|
||||
export function useSafeNavigate(): ((to: string) => void) | null {
|
||||
const inRouter = useInRouterContext();
|
||||
// The router context of a mounted tree never changes, so the hook order is stable.
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
return inRouter ? useNavigate() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up hint tiers inside `root`: later tiers of a group wait (`data-wait`) until the previous tier was opened.
|
||||
* Returns a cleanup function.
|
||||
*/
|
||||
export function wireHintTiers(root: HTMLElement): () => void {
|
||||
const tiers = (group: string) => Array.from(root.querySelectorAll<HTMLDetailsElement>(`details.fold[data-group="${CSS.escape(group)}"]`));
|
||||
for (const d of root.querySelectorAll<HTMLDetailsElement>("details.fold[data-tier]")) {
|
||||
if (Number(d.dataset.tier) > 1 && !d.dataset.seen) {
|
||||
d.dataset.wait = "1";
|
||||
const sum = d.querySelector("summary");
|
||||
sum?.setAttribute("title", `Open hint ${Number(d.dataset.tier) - 1} first`);
|
||||
sum?.setAttribute("aria-disabled", "true");
|
||||
}
|
||||
}
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const sum = (e.target as Element).closest?.("summary");
|
||||
const d = sum?.parentElement as HTMLDetailsElement | null;
|
||||
if (!d || !root.contains(d) || !d.matches("details.fold[data-wait]")) return;
|
||||
e.preventDefault();
|
||||
};
|
||||
const onToggle = (e: Event) => {
|
||||
const d = e.target as HTMLDetailsElement;
|
||||
if (!d.matches?.("details.fold[data-group]") || !d.open) return;
|
||||
if (d.dataset.wait) { d.open = false; return; }
|
||||
d.dataset.seen = "1";
|
||||
const next = tiers(d.dataset.group!).find((x) => Number(x.dataset.tier) === Number(d.dataset.tier) + 1);
|
||||
if (next && next.dataset.wait) {
|
||||
delete next.dataset.wait;
|
||||
const sum = next.querySelector("summary");
|
||||
sum?.removeAttribute("title");
|
||||
sum?.removeAttribute("aria-disabled");
|
||||
}
|
||||
};
|
||||
root.addEventListener("click", onClick);
|
||||
root.addEventListener("toggle", onToggle, true);
|
||||
return () => { root.removeEventListener("click", onClick); root.removeEventListener("toggle", onToggle, true); };
|
||||
}
|
||||
|
||||
/** Sanitized lesson HTML (from `renderLessonHtml`) with hint tiers and in-app links. */
|
||||
export function Prose({ html, className = "prose", style }: { html: string; className?: string; style?: CSSProperties }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const navigate = useSafeNavigate();
|
||||
useEffect(() => {
|
||||
const root = ref.current;
|
||||
if (!root) return;
|
||||
const off = wireHintTiers(root);
|
||||
const onLink = (e: MouseEvent) => {
|
||||
const a = (e.target as Element).closest?.("a");
|
||||
if (!a || !navigate || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
const href = a.getAttribute("href") ?? "";
|
||||
if (href.startsWith("/c/")) { e.preventDefault(); navigate(href); }
|
||||
else if (href.startsWith("#")) {
|
||||
const el = document.getElementById(decodeURIComponent(href.slice(1)));
|
||||
if (el) { e.preventDefault(); el.scrollIntoView({ behavior: "smooth", block: "start" }); }
|
||||
}
|
||||
};
|
||||
root.addEventListener("click", onLink);
|
||||
return () => { off(); root.removeEventListener("click", onLink); };
|
||||
}, [html, navigate]);
|
||||
return <div ref={ref} className={className} style={style} dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Inline `code` spans in titles such as "Exercise 1.1 — `range`". */
|
||||
export function TitleText({ text }: { text: string }) {
|
||||
const parts = text.split(/(`[^`]+`)/g);
|
||||
return <>{parts.map((p, i) => (p.startsWith("`") && p.endsWith("`") && p.length > 2 ? <code key={i}>{p.slice(1, -1)}</code> : p))}</>;
|
||||
}
|
||||
|
||||
export const plainTitle = (t: string) => t.replace(/`/g, "");
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Pill } from "@stc/ui";
|
||||
import type { Note } from "../../shared/types";
|
||||
import { api, fmtTime, lessonHref, toast } from "../api";
|
||||
|
||||
export interface NotesApi {
|
||||
enabled: boolean;
|
||||
notes: Note[];
|
||||
create(n: { anchorKind: Note["anchorKind"]; anchorValue?: number | null; deckId?: string | null; body: string }): Promise<Note | null>;
|
||||
update(id: string, body: string): Promise<void>;
|
||||
remove(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** The signed-in user's notes for one lesson. Disabled (no requests) when signed out or in a preview. */
|
||||
export function useLessonNotes(courseId: string, lessonId: string, enabled: boolean): NotesApi {
|
||||
const [notes, setNotes] = useState<Note[]>([]);
|
||||
useEffect(() => {
|
||||
setNotes([]);
|
||||
if (!enabled) return;
|
||||
let live = true;
|
||||
api<Note[]>(`/api/courses/${encodeURIComponent(courseId)}/notes?lesson=${encodeURIComponent(lessonId)}`).then((n) => live && setNotes(n), () => {});
|
||||
return () => { live = false; };
|
||||
}, [courseId, lessonId, enabled]);
|
||||
const create = useCallback<NotesApi["create"]>(async (n) => {
|
||||
try {
|
||||
const note = await api<Note>(`/api/courses/${encodeURIComponent(courseId)}/notes`, { method: "POST", body: { lessonId, ...n } });
|
||||
setNotes((xs) => [...xs, note]);
|
||||
return note;
|
||||
} catch (e) { toast(`The note was not saved: ${(e as Error).message}`); return null; }
|
||||
}, [courseId, lessonId]);
|
||||
const update = useCallback<NotesApi["update"]>(async (id, body) => {
|
||||
try {
|
||||
const note = await api<Note>(`/api/notes/${encodeURIComponent(id)}`, { method: "PATCH", body: { body } });
|
||||
setNotes((xs) => xs.map((x) => (x.id === id ? note : x)));
|
||||
} catch (e) { toast(`The note was not saved: ${(e as Error).message}`); }
|
||||
}, []);
|
||||
const remove = useCallback<NotesApi["remove"]>(async (id) => {
|
||||
try {
|
||||
await api(`/api/notes/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
setNotes((xs) => xs.filter((x) => x.id !== id));
|
||||
} catch (e) { toast(`The note was not deleted: ${(e as Error).message}`); }
|
||||
}, []);
|
||||
return { enabled, notes, create, update, remove };
|
||||
}
|
||||
|
||||
const STAMP = /\[(?:(\d+):)?(\d{1,2}):(\d{2})\]/g;
|
||||
|
||||
export function parseStamp(h: string | undefined, m: string, s: string): number {
|
||||
return (h ? Number(h) * 3600 : 0) + Number(m) * 60 + Number(s);
|
||||
}
|
||||
|
||||
/** First "[m:ss]" timestamp in a note body, in seconds. */
|
||||
export function firstStamp(body: string): number | null {
|
||||
const m = new RegExp(STAMP.source).exec(body);
|
||||
return m ? parseStamp(m[1], m[2]!, m[3]!) : null;
|
||||
}
|
||||
|
||||
/** Note text with "[m:ss]" timestamps turned into seek buttons (or links to the lesson at that time). */
|
||||
export function NoteBody({ body, onSeek, courseId, lessonId }: { body: string; onSeek?: (s: number) => void; courseId?: string; lessonId?: string }) {
|
||||
const parts: ReactNode[] = [];
|
||||
let last = 0;
|
||||
for (const m of body.matchAll(STAMP)) {
|
||||
const t = parseStamp(m[1], m[2]!, m[3]!);
|
||||
parts.push(body.slice(last, m.index));
|
||||
parts.push(onSeek
|
||||
? <button key={m.index} type="button" className="stamp" onClick={() => onSeek(t)} aria-label={`Play from ${fmtTime(t)}`}>{m[0]}</button>
|
||||
: courseId && lessonId
|
||||
? <Link key={m.index} className="stamp" to={`${lessonHref(courseId, lessonId)}?t=${t}`}>{m[0]}</Link>
|
||||
: m[0]);
|
||||
last = m.index! + m[0].length;
|
||||
}
|
||||
parts.push(body.slice(last));
|
||||
return <div className="nb">{parts}</div>;
|
||||
}
|
||||
|
||||
/** Your notes for a lesson: a composer (with "Insert time" for video) and the list of lesson and video notes. */
|
||||
export function NotesPanel({ notes, video, label = "Your notes" }: {
|
||||
notes: NotesApi;
|
||||
video?: { currentTime: () => number; seek: (s: number) => void } | null;
|
||||
label?: string;
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [now, setNow] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const ta = useRef<HTMLTextAreaElement>(null);
|
||||
useEffect(() => {
|
||||
if (!video) return;
|
||||
const t = setInterval(() => setNow(video.currentTime()), 500);
|
||||
return () => clearInterval(t);
|
||||
}, [video]);
|
||||
|
||||
if (!notes.enabled) {
|
||||
return (
|
||||
<section>
|
||||
<div className="blockhead"><h2>{label}</h2></div>
|
||||
<p className="muted" style={{ fontSize: 13.5, margin: 0 }}><Link to="/account">Sign in</Link> to keep notes on this lesson. Only you see them.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const insertTime = () => {
|
||||
const el = ta.current;
|
||||
const ins = `[${fmtTime(video!.currentTime())}] `;
|
||||
const p = el?.selectionStart ?? draft.length, e = el?.selectionEnd ?? draft.length;
|
||||
const pre = draft.slice(0, p) + (p && draft[p - 1] !== "\n" ? "\n" : "");
|
||||
const next = pre + ins + draft.slice(e);
|
||||
setDraft(next);
|
||||
requestAnimationFrame(() => { el?.focus(); const c = pre.length + ins.length; el?.setSelectionRange(c, c); });
|
||||
};
|
||||
const save = async () => {
|
||||
const body = draft.trim();
|
||||
if (!body) return;
|
||||
setBusy(true);
|
||||
const t = video ? firstStamp(body) : null;
|
||||
const ok = await notes.create(t !== null ? { anchorKind: "video", anchorValue: t, body } : { anchorKind: "none", body });
|
||||
setBusy(false);
|
||||
if (ok) setDraft("");
|
||||
};
|
||||
const list = notes.notes.filter((n) => n.anchorKind !== "slide");
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="blockhead">
|
||||
<h2>{label}</h2>
|
||||
{video ? <Pill className="sm" onClick={insertTime}>Insert time <span className="tnum">{fmtTime(now)}</span></Pill> : null}
|
||||
</div>
|
||||
<textarea
|
||||
ref={ta} className="note-ta" value={draft} onChange={(e) => setDraft(e.target.value)}
|
||||
aria-label="New note" placeholder={video ? "Only you see these. Insert time adds a link back to this point in the video." : "Only you see these."}
|
||||
/>
|
||||
<div className="note-actions">
|
||||
<Pill className="sm" variant="primary" disabled={!draft.trim() || busy} onClick={save}>Save note</Pill>
|
||||
</div>
|
||||
{list.length ? (
|
||||
<ul className="note-list">
|
||||
{list.map((n) => <NoteItem key={n.id} note={n} notes={notes} onSeek={video?.seek} />)}
|
||||
</ul>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoteItem({ note, notes, onSeek, courseId, extra }: { note: Note; notes: Pick<NotesApi, "update" | "remove">; onSeek?: (s: number) => void; courseId?: string; extra?: ReactNode }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [text, setText] = useState(note.body);
|
||||
useEffect(() => { setText(note.body); }, [note.body]);
|
||||
if (editing) {
|
||||
return (
|
||||
<li>
|
||||
<textarea className="note-ta" value={text} onChange={(e) => setText(e.target.value)} aria-label="Edit note" autoFocus
|
||||
onKeyDown={(e) => { if (e.key === "Escape") { setEditing(false); setText(note.body); } }} />
|
||||
<div className="note-actions">
|
||||
<Pill className="sm" variant="ghost" onClick={() => { setEditing(false); setText(note.body); }}>Cancel</Pill>
|
||||
<Pill className="sm" variant="primary" disabled={!text.trim()} onClick={async () => { await notes.update(note.id, text.trim()); setEditing(false); }}>Save</Pill>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li>
|
||||
<NoteBody body={note.body} onSeek={onSeek} courseId={courseId ?? note.courseId} lessonId={note.lessonId} />
|
||||
<div className="nm">
|
||||
{extra}
|
||||
<span>{new Date(note.updatedAt).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" })}</span>
|
||||
<button type="button" onClick={() => setEditing(true)}>Edit</button>
|
||||
<button type="button" onClick={() => { if (confirm("Delete this note?")) void notes.remove(note.id); }}>Delete</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { CourseCover, ProgressBar, SegmentedToggle } from "@stc/ui";
|
||||
import type { CourseCard, ContinueItem, Format, LibraryPayload, SearchHit } from "../../shared/types";
|
||||
import { api, courseHref, ensureStyles, fmtMinutes, lessonHref, refreshLibrary, useLibrary, useSearch } from "../api";
|
||||
import { HeaderExtras } from "../shell/HeaderExtras";
|
||||
import { accentStyle } from "../shell/CourseFrame";
|
||||
|
||||
const FORMAT_LABEL: Record<Format, string> = { text: "Text", video: "Video", slides: "Slides", exercises: "Exercises" };
|
||||
type LengthFilter = "any" | "short" | "medium" | "long";
|
||||
const LENGTHS: { value: LengthFilter; label: string; test: (m: number) => boolean }[] = [
|
||||
{ value: "any", label: "Any length", test: () => true },
|
||||
{ value: "short", label: "Under 2 h", test: (m) => m < 120 },
|
||||
{ value: "medium", label: "2–10 h", test: (m) => m >= 120 && m <= 600 },
|
||||
{ value: "long", label: "Over 10 h", test: (m) => m > 600 },
|
||||
];
|
||||
|
||||
export interface LibraryFilters { format: Format | "all"; length: LengthFilter; inProgress: boolean }
|
||||
|
||||
export function filterCourses(courses: CourseCard[], f: LibraryFilters): CourseCard[] {
|
||||
const len = LENGTHS.find((l) => l.value === f.length)!;
|
||||
return courses.filter((c) => (f.format === "all" || c.formats.includes(f.format)) && len.test(c.minutes) && (!f.inProgress || (c.started && c.progress < 1)));
|
||||
}
|
||||
|
||||
export default function LibraryPage() {
|
||||
ensureStyles();
|
||||
const lib = useLibrary();
|
||||
useEffect(() => { void refreshLibrary(); }, []);
|
||||
useEffect(() => { document.title = "Library – STCourses"; }, []);
|
||||
return (
|
||||
<div className="lr" data-look="work">
|
||||
<HeaderExtras course={null} lookMode={null} />
|
||||
{lib ? <LibraryView lib={lib} /> : <div className="lr-lib"><p className="lib-sub">Loading the library…</p></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LibraryView({ lib, initialQuery }: { lib: LibraryPayload; initialQuery?: string }) {
|
||||
const [storeQ] = useSearch();
|
||||
const q = (initialQuery ?? storeQ).trim();
|
||||
const [filters, setFilters] = useState<LibraryFilters>({ format: "all", length: "any", inProgress: false });
|
||||
const [hits, setHits] = useState<SearchHit[] | null>(null);
|
||||
useEffect(() => {
|
||||
if (!q) { setHits(null); return; }
|
||||
let live = true;
|
||||
const t = setTimeout(() => {
|
||||
api<SearchHit[]>(`/api/library/search?q=${encodeURIComponent(q)}`).then((h) => live && setHits(h), () => live && setHits([]));
|
||||
}, 180);
|
||||
return () => { live = false; clearTimeout(t); };
|
||||
}, [q]);
|
||||
|
||||
const byId = useMemo(() => new Map(lib.courses.map((c) => [c.id, c])), [lib]);
|
||||
const filtering = filters.format !== "all" || filters.length !== "any" || filters.inProgress;
|
||||
const browsing = !q && !filtering;
|
||||
const formats = useMemo(() => (["text", "video", "slides", "exercises"] as Format[]).filter((f) => lib.courses.some((c) => c.formats.includes(f))), [lib]);
|
||||
const n = lib.courses.length;
|
||||
|
||||
let results: CourseCard[] = filterCourses(lib.courses, filters);
|
||||
if (q && hits) {
|
||||
const matched = new Set(hits.filter((h) => h.course).map((h) => h.courseId));
|
||||
results = results.filter((c) => matched.has(c.id));
|
||||
}
|
||||
const lessonHits = (hits ?? []).filter((h) => h.lessons.length && byId.has(h.courseId) && filterCourses([byId.get(h.courseId)!], filters).length);
|
||||
|
||||
return (
|
||||
<div className="lr-lib">
|
||||
<h1 className="lib-h">Library</h1>
|
||||
<p className="lib-sub">{n === 0 ? "No courses you can open yet." : n === 1 ? "One course on this server." : `${n} courses on this server.`}{!lib.signedIn && n > 0 ? " Sign in to keep your progress and notes." : ""}</p>
|
||||
|
||||
{browsing && lib.featured ? <Featured c={lib.featured} cont={lib.continue.find((x) => x.courseId === lib.featured!.id) ?? null} /> : null}
|
||||
|
||||
{browsing && lib.continue.length ? (
|
||||
<section aria-labelledby="lr-cont">
|
||||
<h2 className="sec-h" id="lr-cont">Continue</h2>
|
||||
<div className="continue-list">
|
||||
{lib.continue.slice(0, 3).map((c) => <ContinueRow key={c.courseId} item={c} course={byId.get(c.courseId)} />)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{n > 0 ? (
|
||||
<div className="filters" role="group" aria-label="Filters">
|
||||
{formats.length > 1 ? (
|
||||
<SegmentedToggle label="Format" value={filters.format} onChange={(v) => setFilters({ ...filters, format: v as LibraryFilters["format"] })}
|
||||
options={[{ value: "all", label: "All formats" }, ...formats.map((f) => ({ value: f, label: FORMAT_LABEL[f] }))]} />
|
||||
) : null}
|
||||
<SegmentedToggle label="Length" value={filters.length} onChange={(v) => setFilters({ ...filters, length: v as LengthFilter })}
|
||||
options={LENGTHS.map((l) => ({ value: l.value, label: l.label }))} />
|
||||
{lib.signedIn ? (
|
||||
<SegmentedToggle label="Progress" value={filters.inProgress ? "in" : "all"} onChange={(v) => setFilters({ ...filters, inProgress: v === "in" })}
|
||||
options={[{ value: "all", label: "Everything" }, { value: "in", label: "In progress" }]} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{browsing ? (
|
||||
<>
|
||||
<Strip title="New in the library" courses={lib.newest.map((id) => byId.get(id)!).filter(Boolean).slice(0, 12)} signedIn={lib.signedIn} />
|
||||
{lib.collections.map((col) => (
|
||||
<Strip key={col.id} title={col.title} courses={col.courseIds.map((id) => byId.get(id)!).filter(Boolean)} signedIn={lib.signedIn} />
|
||||
))}
|
||||
<h2 className="sec-h">All courses</h2>
|
||||
<CourseTable courses={lib.courses} signedIn={lib.signedIn} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="sec-h">{q ? `Courses matching “${q}”` : "Courses"}</h2>
|
||||
{q && hits === null ? <p className="lr-nohit">Searching…</p> : results.length ? <CourseTable courses={results} signedIn={lib.signedIn} /> : <p className="lr-nohit">No courses match.</p>}
|
||||
{lessonHits.length ? (
|
||||
<>
|
||||
<h2 className="sec-h" style={{ marginTop: 36 }}>Lessons</h2>
|
||||
<ul className="hits">
|
||||
{lessonHits.flatMap((h) => h.lessons.map((l) => (
|
||||
<li key={`${h.courseId}/${l.id}`}>
|
||||
<Link to={lessonHref(h.courseId, l.id)}>
|
||||
<span><span className="h1">{l.title}</span><span className="h2" style={{ display: "block" }}>{h.courseTitle} · {l.moduleTitle}</span></span>
|
||||
</Link>
|
||||
</li>
|
||||
)))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function facts(c: CourseCard): string[] {
|
||||
const out = [c.lessonCount === 1 ? "1 lesson" : `${c.lessonCount} lessons`];
|
||||
if (c.minutes) out.push(`about ${fmtMinutes(c.minutes)}`);
|
||||
if (c.tracks.length > 1) out.push(`${c.tracks.length} tracks`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function Featured({ c, cont }: { c: CourseCard; cont: ContinueItem | null }) {
|
||||
const target = cont ? lessonHref(c.id, cont.lessonId) : courseHref(c.id);
|
||||
return (
|
||||
<section className="feat" aria-label="Featured course" style={accentStyle(c.accent)}>
|
||||
<Link to={courseHref(c.id)} tabIndex={-1} aria-hidden="true" style={{ "--cw": "168px" } as CSSProperties}>
|
||||
<CourseCover title={c.title} image={c.coverUrl ?? undefined} accent={c.accent ?? undefined} />
|
||||
</Link>
|
||||
<div>
|
||||
<p className="k">Featured</p>
|
||||
<h2><Link to={courseHref(c.id)}>{c.title}</Link></h2>
|
||||
{c.pitch ? <p className="pitch">{c.pitch}</p> : null}
|
||||
<div className="facts">{facts(c).map((f) => <span key={f}>{f}</span>)}{c.formats.map((f) => <span key={f}>{FORMAT_LABEL[f]}</span>)}</div>
|
||||
<div className="actions">
|
||||
<Link className="stc-pill stc-pill--primary" to={target}>{cont ? "Continue" : c.started ? "Open" : "Start"}</Link>
|
||||
{c.started ? <span className="lr-prog" style={{ margin: 0, minWidth: 160 }}><ProgressBar value={c.progress} label="Progress" /><span className="tnum">{Math.round(c.progress * 100)}%</span></span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ContinueRow({ item, course }: { item: ContinueItem; course: CourseCard | undefined }) {
|
||||
const title = course?.title ?? item.courseId;
|
||||
return (
|
||||
<div className="continue" style={accentStyle(course?.accent)}>
|
||||
<span style={{ "--cw": "56px" } as CSSProperties}><CourseCover title={title} image={course?.coverUrl ?? undefined} accent={course?.accent ?? undefined} /></span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="t">{title}</div>
|
||||
<div className="s">{item.moduleTitle}. Next up: {item.lessonTitle}</div>
|
||||
<div className="lr-prog"><ProgressBar value={item.moduleTotal ? item.moduleDone / item.moduleTotal : 0} label="Module progress" /><span className="tnum">{item.moduleDone} of {item.moduleTotal} in this module</span></div>
|
||||
</div>
|
||||
<Link className="stc-pill stc-pill--primary" to={lessonHref(item.courseId, item.lessonId)}>Continue</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Strip({ title, courses, signedIn }: { title: string; courses: CourseCard[]; signedIn: boolean }) {
|
||||
if (!courses.length) return null;
|
||||
return (
|
||||
<section className="strip-wrap" aria-label={title}>
|
||||
<h2 className="sec-h">{title}</h2>
|
||||
<div className="strip">
|
||||
{courses.map((c) => (
|
||||
<Link key={c.id} className="tile" to={courseHref(c.id)} style={accentStyle(c.accent)}>
|
||||
<CourseCover title={c.title} image={c.coverUrl ?? undefined} accent={c.accent ?? undefined} />
|
||||
<span className="tt">{c.title}</span>
|
||||
<span className="ts">{facts(c).slice(0, 2).join(", ")}</span>
|
||||
{signedIn && c.started ? <ProgressBar value={c.progress} label={`${c.title} progress`} /> : null}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CourseTable({ courses, signedIn }: { courses: CourseCard[]; signedIn: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<div className="lib-cols" aria-hidden="true"><span>Course</span><span>Lessons</span><span>Contains</span><span>{signedIn ? "Progress" : "Length"}</span></div>
|
||||
<div className="lib">
|
||||
{courses.map((c) => (
|
||||
<Link key={c.id} className="lib-row" to={courseHref(c.id)} style={accentStyle(c.accent)}>
|
||||
<span className="lib-main">
|
||||
<span style={{ "--cw": "44px" } as CSSProperties}><CourseCover title={c.title} image={c.coverUrl ?? undefined} accent={c.accent ?? undefined} /></span>
|
||||
<span style={{ minWidth: 0 }}>
|
||||
<span className="lt">{c.title}</span>
|
||||
<span className="ls">{c.pitch ?? c.authors.join(", ")}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="lnum tnum">{c.lessonCount}</span>
|
||||
<span className="tags">
|
||||
{c.status === "error" ? <span className="tag bad">Has problems</span> : null}
|
||||
{c.formats.map((f) => <span key={f} className="tag">{FORMAT_LABEL[f]}</span>)}
|
||||
</span>
|
||||
{signedIn
|
||||
? <span className="lr-prog"><ProgressBar value={c.progress} label={`${c.title} progress`} /><span className="tnum" style={{ minWidth: "3ch", textAlign: "right" }}>{Math.round(c.progress * 100)}%</span></span>
|
||||
: <span className="lnum">{c.minutes ? fmtMinutes(c.minutes) : ""}</span>}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { CourseCover, ProgressBar } from "@stc/ui";
|
||||
import type { CoursePayload, OutlineLesson, OutlineModule } from "../../shared/types";
|
||||
import { courseHref, ensureStyles, fmtMinutes, fmtTime, lessonHref, useSearch, type Look } from "../api";
|
||||
import { Check, Chev, Lock } from "../icons";
|
||||
import { HeaderExtras } from "./HeaderExtras";
|
||||
import { TitleText } from "../lesson/TitleText";
|
||||
|
||||
const fold = (s: string) => s.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase();
|
||||
|
||||
export function lessonKind(l: OutlineLesson): string {
|
||||
if (l.type === "video") return l.minutes ? (l.minutes < 60 ? fmtTime(l.minutes * 60) : fmtMinutes(l.minutes)) : "Video";
|
||||
if (l.type === "exercise") return "Exercise";
|
||||
return l.hasDeck ? "Slides" : "Text";
|
||||
}
|
||||
|
||||
export function courseDone(c: CoursePayload): { done: number; total: number } {
|
||||
const all = c.modules.flatMap((m) => m.lessons);
|
||||
return { done: all.filter((l) => c.progress[l.id]?.state === "done").length, total: all.length };
|
||||
}
|
||||
|
||||
/** Display number of a module: courses whose first module id ends in 0 ("m00") count from 0. */
|
||||
export function moduleNumber(course: { modules: { id: string }[] }, index: number): number {
|
||||
return /(^|\D)0+$/.test(course.modules[0]?.id ?? "") ? index : index + 1;
|
||||
}
|
||||
|
||||
export function accentStyle(accent: string | null | undefined): CSSProperties | undefined {
|
||||
return accent ? ({ "--accent": accent } as CSSProperties) : undefined;
|
||||
}
|
||||
|
||||
/** The course page frame: outline sidebar (an overlay on narrow screens) and the main column. */
|
||||
export function CourseFrame({ course, currentLessonId, look, lookMode, children }: {
|
||||
course: CoursePayload;
|
||||
currentLessonId: string | null;
|
||||
look: Look;
|
||||
lookMode: "toggle" | "work" | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
ensureStyles();
|
||||
const [open, setOpen] = useState(false);
|
||||
const loc = useLocation();
|
||||
useEffect(() => { setOpen(false); }, [loc.pathname]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
return (
|
||||
<div className={open ? "lr lr-shell side-open" : "lr lr-shell"} data-look={look} style={accentStyle(course.accent)}>
|
||||
<HeaderExtras
|
||||
course={{ id: course.id, title: course.title, coverUrl: course.coverUrl, accent: course.accent }}
|
||||
lookMode={lookMode}
|
||||
outline={{ open, toggle: () => setOpen(!open) }}
|
||||
/>
|
||||
<aside className="lr-side" id="lr-side" aria-label="Course outline">
|
||||
<Outline course={course} currentLessonId={currentLessonId} />
|
||||
</aside>
|
||||
<div className="lr-scrim" onClick={() => setOpen(false)} />
|
||||
<main className="lr-main">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Outline({ course, currentLessonId }: { course: CoursePayload; currentLessonId: string | null }) {
|
||||
const [q] = useSearch();
|
||||
const query = fold(q.trim());
|
||||
const current = course.modules.find((m) => m.lessons.some((l) => l.id === currentLessonId));
|
||||
const [openMods, setOpenMods] = useState<Set<string>>(() => new Set(current ? [current.id] : course.modules[0] ? [course.modules[0].id] : []));
|
||||
useEffect(() => { if (current) setOpenMods((s) => (s.has(current.id) ? s : new Set([...s, current.id]))); }, [current?.id]);
|
||||
const loc = useLocation();
|
||||
useEffect(() => {
|
||||
document.querySelector(".lr-les[aria-current='page']")?.scrollIntoView({ block: "nearest" });
|
||||
}, [currentLessonId]);
|
||||
const { done, total } = courseDone(course);
|
||||
const pct = total ? done / total : 0;
|
||||
const notesPath = `${courseHref(course.id)}/notes`;
|
||||
|
||||
const filtered = useMemo(() => course.modules
|
||||
.map((m) => ({ m, lessons: query ? m.lessons.filter((l) => fold(l.title).includes(query)) : m.lessons }))
|
||||
.filter(({ m, lessons }) => !query || lessons.length || fold(m.title).includes(query)), [course, query]);
|
||||
|
||||
const toggle = (id: string) => setOpenMods((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; });
|
||||
const sourceLine = course.authors.length ? course.authors.join(", ") : course.tracks.length > 1 ? `Tracks: ${course.tracks.map((t) => t.label).join(", ")}` : course.pitch ?? "";
|
||||
const errors = course.diagnostics.filter((d) => d.level === "error");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="lr-side-head">
|
||||
<span style={{ "--cw": "36px" } as CSSProperties}><CourseCover title={course.title} image={course.coverUrl ?? undefined} accent={course.accent ?? undefined} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h2><Link to={courseHref(course.id)}>{course.title}</Link></h2>
|
||||
{sourceLine ? <div className="src">{sourceLine}</div> : null}
|
||||
{course.signedIn ? (
|
||||
<div className="lr-prog"><ProgressBar value={pct} label="Course progress" /><span className="tnum">{Math.round(pct * 100)}%</span></div>
|
||||
) : null}
|
||||
<div className="lr-side-links">
|
||||
<Link to={courseHref(course.id)} aria-current={loc.pathname === courseHref(course.id) ? "page" : undefined}>Overview</Link>
|
||||
{course.signedIn ? <Link to={notesPath} aria-current={loc.pathname === notesPath ? "page" : undefined}>Your notes</Link> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{errors.length ? (
|
||||
<div className="lr-diag" role="note">
|
||||
<b>{errors.length === 1 ? "1 problem" : `${errors.length} problems`} in this course</b> (only authors see this)
|
||||
<ul>{errors.slice(0, 6).map((d, i) => <li key={i}>{d.lessonId ? `${d.lessonId}: ` : ""}{d.message}{d.path ? ` (${d.path})` : ""}</li>)}</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{filtered.map(({ m, lessons }) => (
|
||||
<ModuleBlock key={m.id} course={course} m={m} lessons={lessons} open={!!query || openMods.has(m.id)} current={m.id === current?.id} currentLessonId={currentLessonId} onToggle={() => toggle(m.id)} />
|
||||
))}
|
||||
{query && filtered.length === 0 ? <p className="lr-nohit">No lessons match “{q.trim()}”.</p> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleBlock({ course, m, lessons, open, current, currentLessonId, onToggle }: {
|
||||
course: CoursePayload; m: OutlineModule; lessons: OutlineLesson[]; open: boolean; current: boolean; currentLessonId: string | null; onToggle: () => void;
|
||||
}) {
|
||||
const d = m.lessons.filter((l) => course.progress[l.id]?.state === "done").length;
|
||||
const pct = m.lessons.length ? d / m.lessons.length : 0;
|
||||
const number = String(moduleNumber(course, m.index));
|
||||
return (
|
||||
<div className={current ? "lr-mod is-current" : "lr-mod"}>
|
||||
<button type="button" className="lr-mod-head" aria-expanded={open} onClick={onToggle}>
|
||||
<span className="n tnum">{m.locked ? <Lock /> : number}</span>
|
||||
<span>{m.title}{m.locked ? <span className="sr"> (locked)</span> : null}</span>
|
||||
<span className="c tnum">{course.signedIn ? `${d}/${m.lessons.length}` : m.lessons.length}</span>
|
||||
<Chev />
|
||||
</button>
|
||||
{course.signedIn ? <ProgressBar value={pct} dim={pct === 1 || !current} label={`${m.title} progress`} /> : <span style={{ display: "block", height: 4 }} />}
|
||||
{open ? (
|
||||
<ol className="lr-lessons">
|
||||
{lessons.map((l) => {
|
||||
const isDone = course.progress[l.id]?.state === "done";
|
||||
const cls = ["lr-les", isDone && "done", m.locked && "locked"].filter(Boolean).join(" ");
|
||||
return (
|
||||
<li key={l.id}>
|
||||
<Link className={cls} to={lessonHref(course.id, l.id)} aria-current={l.id === currentLessonId ? "page" : undefined}>
|
||||
<span className="m">{isDone ? <Check /> : m.locked ? <Lock /> : null}</span>
|
||||
<span><TitleText text={l.title} /><span className="sr">{isDone ? ", done" : l.id === currentLessonId ? ", current" : ""}</span></span>
|
||||
<span className="d tnum">{lessonKind(l)}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { CourseCover, SegmentedToggle } from "@stc/ui";
|
||||
import { courseHref, toast, useLibrary, useLook, useMe, useSearch, useToast } from "../api";
|
||||
import { DirI, Down, MenuI, SearchI } from "../icons";
|
||||
|
||||
export interface HeaderCourse { id: string; title: string; coverUrl: string | null; accent: string | null }
|
||||
|
||||
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||
|
||||
/**
|
||||
* Course switcher, search, Reading/Work and the account link, portalled into the Phase 0 header (`.stc-top`).
|
||||
* Rendered only after mount, so server-side rendering of a page skips it.
|
||||
*/
|
||||
export function HeaderExtras({ course, lookMode, outline }: {
|
||||
course: HeaderCourse | null;
|
||||
/** "toggle": show Reading/Work; "work": shown but fixed to Work (exercise pages); null: hidden. */
|
||||
lookMode: "toggle" | "work" | null;
|
||||
outline?: { open: boolean; toggle: () => void } | null;
|
||||
}) {
|
||||
const [host, setHost] = useState<Element | null>(null);
|
||||
useIsoLayoutEffect(() => { setHost(document.querySelector(".stc-top")); }, []);
|
||||
const msg = useToast();
|
||||
if (!host) return null;
|
||||
return (
|
||||
<>
|
||||
{createPortal(
|
||||
<div className="lr lr-h">
|
||||
{outline ? (
|
||||
<button type="button" className="lr-icon" aria-label="Course outline" aria-expanded={outline.open} aria-controls="lr-side" onClick={outline.toggle}><MenuI /></button>
|
||||
) : null}
|
||||
<CourseSwitcher course={course} />
|
||||
<SearchBox placeholder={course ? "Search this course" : "Search the library"} />
|
||||
{lookMode ? <LookToggle mode={lookMode} /> : null}
|
||||
<AccountLink />
|
||||
</div>,
|
||||
host,
|
||||
)}
|
||||
{msg ? <div className="lr lr-toast" role="status">{msg}</div> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LookToggle({ mode }: { mode: "toggle" | "work" }) {
|
||||
const [look, setLook] = useLook();
|
||||
const value = mode === "work" ? "work" : look;
|
||||
return (
|
||||
<SegmentedToggle
|
||||
label="Look"
|
||||
value={value}
|
||||
options={[{ value: "reading", label: "Reading" }, { value: "work", label: "Work" }]}
|
||||
onChange={(v) => {
|
||||
setLook(v as "reading" | "work");
|
||||
if (mode === "work" && v === "reading") toast("Exercises always use Work. Reading applies to text lessons.");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchBox({ placeholder }: { placeholder: string }) {
|
||||
const [q, setQ] = useSearch();
|
||||
const loc = useLocation();
|
||||
const path = useRef(loc.pathname.split("/").slice(0, 3).join("/"));
|
||||
// Clear the query when moving to another course or to the library.
|
||||
useEffect(() => {
|
||||
const p = loc.pathname.split("/").slice(0, 3).join("/");
|
||||
if (p !== path.current) { path.current = p; setQ(""); }
|
||||
}, [loc.pathname, setQ]);
|
||||
return (
|
||||
<label className="lr-search">
|
||||
<SearchI />
|
||||
<span className="sr">Search</span>
|
||||
<input
|
||||
type="search" value={q} placeholder={placeholder} autoComplete="off"
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") { setQ(""); (e.target as HTMLInputElement).blur(); } }}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function CourseSwitcher({ course }: { course: HeaderCourse | null }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const lib = useLibrary();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const loc = useLocation();
|
||||
useEffect(() => { setOpen(false); }, [loc.pathname]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false); };
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") { setOpen(false); ref.current?.querySelector<HTMLButtonElement>("button")?.focus(); }
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
document.addEventListener("keydown", onKey);
|
||||
ref.current?.querySelector<HTMLAnchorElement>(".lr-menu a")?.focus();
|
||||
return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
|
||||
}, [open]);
|
||||
const n = lib?.courses.length ?? 0;
|
||||
return (
|
||||
<div className="lr-switch" ref={ref}>
|
||||
<button type="button" className="lr-switch-btn" aria-haspopup="true" aria-expanded={open} onClick={() => setOpen(!open)}>
|
||||
{course
|
||||
? <span className="lr-cover-tiny"><CourseCover title={course.title} image={course.coverUrl ?? undefined} accent={course.accent ?? undefined} /></span>
|
||||
: <span className="lr-tile"><DirI /></span>}
|
||||
<span className="t">{course ? course.title : "All courses"}</span>
|
||||
<Down />
|
||||
</button>
|
||||
{open ? (
|
||||
<ul className="lr-menu">
|
||||
<li>
|
||||
<Link to="/" aria-current={course ? undefined : "page"}>
|
||||
<span className="lr-tile"><DirI /></span>
|
||||
<span><span className="mt">Library</span><span className="ms">{n === 1 ? "1 course" : `${n} courses`}</span></span>
|
||||
</Link>
|
||||
</li>
|
||||
{(lib?.courses ?? []).map((c) => (
|
||||
<li key={c.id}>
|
||||
<Link to={courseHref(c.id)} aria-current={course?.id === c.id ? "page" : undefined}>
|
||||
<span className="lr-cover-tiny"><CourseCover title={c.title} image={c.coverUrl ?? undefined} accent={c.accent ?? undefined} /></span>
|
||||
<span style={{ minWidth: 0 }}><span className="mt">{c.title}</span><span className="ms">{c.authors.join(", ") || (c.source === "authored" ? "Authored course" : c.source ? `Imported (${c.source})` : "")}</span></span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountLink() {
|
||||
const { loaded, user } = useMe();
|
||||
if (!loaded) return null;
|
||||
return user
|
||||
? <Link className="stc-pill stc-pill--ghost lr-acct" to="/account">{user.name || user.email}</Link>
|
||||
: <Link className="stc-pill lr-acct" to="/account">Sign in</Link>;
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
// The learner stylesheet, injected once by ensureStyles(). Kept as a string (not a .css text import) because
|
||||
// Bun's dev server loses text imports on hot rebuilds. Scoped under .lr; no colours beyond the @stc/ui tokens.
|
||||
export const learnerCss = String.raw`
|
||||
/* Learner app. Scoped under .lr so the studio can embed LessonView without clashes.
|
||||
Tokens come from @stc/ui (tokens.css); this file adds no colours of its own. */
|
||||
|
||||
.lr { --accent-dim: color-mix(in srgb, var(--accent) 55%, var(--ground)); }
|
||||
.lr[data-look="reading"], .lr [data-look="reading"] { --pf: var(--serif); --ps: 19px; --pl: 1.75; --pc: var(--read); --col: 660px; --wide: 900px; --tf: var(--serif); --h1: 40px; }
|
||||
.lr[data-look="work"], .lr [data-look="work"] { --pf: var(--sans); --ps: 15.5px; --pl: 1.6; --pc: var(--text); --col: 760px; --wide: 1180px; --tf: var(--sans); --h1: 28px; }
|
||||
.lr [hidden] { display: none !important; }
|
||||
.lr .tnum { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ---------- header extras (portalled into .stc-top) ---------- */
|
||||
.lr-h { display: contents; }
|
||||
.stc-top > a:first-child { flex: 0 0 calc(var(--sidew) - 30px); }
|
||||
.lr-icon { display: none; width: 34px; height: 34px; border-radius: 18px; border: 1px solid var(--line2); background: none; place-items: center; color: var(--muted); flex: none; }
|
||||
.lr-icon:hover { background: var(--raised); }
|
||||
.lr-switch { position: relative; flex: none; }
|
||||
.lr-switch-btn { display: flex; align-items: center; gap: 10px; height: 34px; padding: 0 12px 0 5px; border: 1px solid var(--line2); border-radius: 18px; background: none; font-weight: 500; font-size: 13px; max-width: 300px; }
|
||||
.lr-switch-btn:hover { background: var(--raised); }
|
||||
.lr-switch-btn .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.lr-switch-btn svg { color: var(--faint); flex: none; }
|
||||
.lr-menu { position: absolute; top: 42px; left: 0; width: 330px; max-height: calc(100vh - 80px); overflow-y: auto; margin: 0; padding: 6px; list-style: none; background: var(--side); border: 1px solid var(--line2); border-radius: 14px; z-index: 50; }
|
||||
.lr-menu a { display: flex; gap: 12px; align-items: center; padding: 8px 10px; border-radius: 10px; text-decoration: none; }
|
||||
.lr-menu a:hover, .lr-menu a:focus-visible { background: var(--raised); }
|
||||
.lr-menu a[aria-current] .mt { color: var(--head); }
|
||||
.lr-menu .mt { font-weight: 500; font-size: 13.5px; display: block; }
|
||||
.lr-menu .ms { display: block; font-size: 12px; color: var(--faint); }
|
||||
.lr-tile { width: 24px; display: grid; place-items: center; color: var(--faint); flex: none; }
|
||||
.lr-search { flex: 1; max-width: 380px; display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 12px; border: 1px solid var(--line2); border-radius: 18px; color: var(--faint); margin-left: auto; }
|
||||
.lr-search:focus-within { border-color: var(--faint); }
|
||||
.lr-search input { flex: 1; min-width: 0; border: 0; background: none; outline: 0; color: var(--text); font-size: 13px; }
|
||||
.lr-search input::placeholder { color: var(--faint); }
|
||||
.lr-search input::-webkit-search-cancel-button { filter: grayscale(1); }
|
||||
.lr-acct { flex: none; }
|
||||
.lr-cover-tiny { --cw: 24px; }
|
||||
.lr-cover-tiny .stc-cover { border-radius: 4px; font-size: 9px; }
|
||||
|
||||
/* ---------- course shell ---------- */
|
||||
.stc-main:has(> .lr-shell) { padding: 0; }
|
||||
.lr-shell { display: flex; align-items: flex-start; }
|
||||
.lr-side { position: sticky; top: var(--top); flex: none; width: var(--sidew); height: calc(100vh - var(--top)); overflow-y: auto; background: var(--side); border-right: 1px solid var(--line); overscroll-behavior: contain; }
|
||||
.lr-scrim { display: none; }
|
||||
.lr-main { flex: 1; min-width: 0; padding: 44px 40px 0; }
|
||||
.lr-side-head { display: flex; gap: 12px; padding: 18px 16px 16px; border-bottom: 1px solid var(--line); }
|
||||
.lr-side-head h2 { margin: 0; font-size: 14.5px; font-weight: 600; color: var(--head); line-height: 1.3; }
|
||||
.lr-side-head h2 a { text-decoration: none; }
|
||||
.lr-side-head .src { font-size: 12px; color: var(--faint); margin-top: 2px; }
|
||||
.lr-side-links { display: flex; gap: 14px; margin-top: 8px; font-size: 12px; }
|
||||
.lr-side-links a { color: var(--muted); text-decoration: none; }
|
||||
.lr-side-links a:hover, .lr-side-links a[aria-current] { color: var(--head); }
|
||||
.lr-prog { display: flex; align-items: center; gap: 10px; margin-top: 10px; font-size: 12px; color: var(--muted); }
|
||||
.lr-mod { border-bottom: 1px solid var(--line); }
|
||||
.lr-mod-head { width: 100%; display: grid; grid-template-columns: 22px 1fr auto 12px; gap: 6px; align-items: baseline; padding: 11px 16px 7px; border: 0; background: none; text-align: left; font-size: 13.5px; color: var(--text); }
|
||||
.lr-mod-head:hover { color: var(--head); }
|
||||
.lr-mod-head .n, .lr-mod-head .c { color: var(--faint); font-size: 12px; }
|
||||
.lr-mod-head svg { color: var(--faint); align-self: center; }
|
||||
.lr-mod-head > svg { transition: transform var(--fast); }
|
||||
.lr-mod-head[aria-expanded="true"] > svg { transform: rotate(90deg); }
|
||||
.lr-mod.is-current .lr-mod-head { color: var(--head); font-weight: 500; }
|
||||
.lr-mod > .stc-bar { margin: 0 16px 11px 44px; flex: none; }
|
||||
.lr-lessons { list-style: none; margin: -3px 0 0; padding: 0 0 8px; }
|
||||
.lr-les { display: grid; grid-template-columns: 22px 1fr auto; gap: 6px; align-items: center; padding: 6px 16px; font-size: 13px; line-height: 1.35; color: var(--muted); text-decoration: none; }
|
||||
.lr-les:hover { background: color-mix(in srgb, var(--raised) 60%, transparent); color: var(--text); }
|
||||
.lr-les .m { width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid var(--line2); justify-self: center; display: grid; place-items: center; }
|
||||
.lr-les.done .m { border: 0; width: 12px; height: 12px; color: var(--faint); }
|
||||
.lr-les.locked { color: var(--faint); }
|
||||
.lr-les.locked .m { border: 0; width: 12px; height: 12px; color: var(--faint); }
|
||||
.lr-les .d { font-size: 12px; color: var(--faint); }
|
||||
.lr-les code, .lr-menu code { font-size: .92em; }
|
||||
.lr-les[aria-current="page"] { background: var(--raised); color: var(--head); box-shadow: inset 2px 0 0 var(--accent); }
|
||||
.lr-les[aria-current="page"] .m { border: 0; background: var(--accent); width: 8px; height: 8px; }
|
||||
.lr-les[aria-current="page"] .m svg { display: none; }
|
||||
.lr-les[aria-current="page"].done .m svg { display: block; }
|
||||
.lr-les[aria-current="page"].done .m { background: none; color: var(--accent); width: 12px; height: 12px; }
|
||||
.lr-nohit { padding: 16px; color: var(--faint); font-size: 13px; }
|
||||
.lr-diag { margin: 12px 16px; padding: 10px 12px; border: 1px solid var(--line2); border-radius: 10px; font-size: 12px; color: var(--muted); }
|
||||
.lr-diag b { color: var(--bad); font-weight: 500; }
|
||||
.lr-diag ul { margin: 6px 0 0; padding-left: 16px; }
|
||||
|
||||
/* ---------- columns and headings ---------- */
|
||||
.lr .col { max-width: var(--col); margin: 0 auto; }
|
||||
.lr .wide { max-width: var(--wide); margin: 0 auto; }
|
||||
.lr .chap { font-size: 13px; font-weight: 500; color: var(--muted); margin: 0 0 10px; }
|
||||
.lr h1.title { font: 600 var(--h1)/1.12 var(--tf); color: var(--head); margin: 0; letter-spacing: -.012em; text-wrap: balance; }
|
||||
.lr h1.title code { font-size: .86em; }
|
||||
.lr .meta { display: flex; flex-wrap: wrap; gap: 14px 30px; margin: 22px 0 0; padding-bottom: 22px; border-bottom: 1px solid var(--line); align-items: end; }
|
||||
.lr .meta div { min-width: 0; }
|
||||
.lr .meta dt { font-size: 12px; color: var(--faint); margin-bottom: 2px; }
|
||||
.lr .meta dd { margin: 0; font-size: 13.5px; color: var(--text); }
|
||||
.lr .meta dd code { font-size: 12.5px; }
|
||||
.lr .source { font-size: 12.5px; color: var(--faint); margin: 14px 0 0; }
|
||||
.lr .source strong { font-weight: 500; color: var(--muted); }
|
||||
.lr .blockhead { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 0 0 10px; flex-wrap: wrap; }
|
||||
.lr .blockhead h2 { margin: 0; font: 600 15px/1.3 var(--sans); color: var(--head); }
|
||||
.lr .blockhead > span { font-size: 12px; color: var(--faint); }
|
||||
.lr .sec-h { font: 600 14px var(--sans); color: var(--muted); margin: 0 0 10px; }
|
||||
.lr .muted { color: var(--faint); }
|
||||
.lr .lr-block { margin-top: 40px; }
|
||||
|
||||
/* ---------- prose ---------- */
|
||||
.lr .prose { font-family: var(--pf); font-size: var(--ps); line-height: var(--pl); color: var(--pc); overflow-wrap: break-word; }
|
||||
.lr .prose p, .lr .prose ul, .lr .prose ol, .lr .prose dl, .lr .prose table { margin: 0 0 1em; }
|
||||
.lr .prose > :first-child { margin-top: 0; }
|
||||
.lr .prose h2 { font: 600 1.32em/1.25 var(--tf); color: var(--head); margin: 2em 0 .55em; letter-spacing: -.005em; }
|
||||
.lr .prose h3 { font: 600 1.08em/1.3 var(--tf); color: var(--head); margin: 1.7em 0 .45em; }
|
||||
.lr .prose h4, .lr .prose h5, .lr .prose h6 { font: 600 1em/1.35 var(--tf); color: var(--head); margin: 1.4em 0 .4em; }
|
||||
.lr .prose strong { font-weight: 600; color: var(--head); }
|
||||
.lr .prose li { margin-bottom: .35em; padding-left: .2em; }
|
||||
.lr .prose a { color: var(--head); text-decoration-color: var(--line2); text-underline-offset: 3px; }
|
||||
.lr .prose a:hover { text-decoration-color: var(--accent); }
|
||||
.lr .prose img { max-width: 100%; height: auto; border-radius: 8px; }
|
||||
.lr .prose blockquote { border-left: 2px solid var(--line2); padding-left: 18px; color: var(--muted); font-size: .92em; margin: 0 0 1.2em; }
|
||||
.lr .prose blockquote strong { color: var(--text); }
|
||||
.lr .prose hr { border: 0; border-top: 1px solid var(--line); margin: 2em 0; }
|
||||
.lr .prose table { border-collapse: collapse; font-family: var(--sans); font-size: 14px; display: block; overflow-x: auto; }
|
||||
.lr .prose th, .lr .prose td { border-bottom: 1px solid var(--line); padding: 6px 12px 6px 0; text-align: left; vertical-align: top; }
|
||||
.lr .prose th { color: var(--muted); font-weight: 500; }
|
||||
.lr .prose :not(pre) > code { font-size: .78em; padding: .08em .32em; background: color-mix(in srgb, var(--raised) 75%, transparent); border-radius: 4px; color: var(--text); }
|
||||
.lr pre.code { font-size: 13.5px; line-height: 1.6; background: var(--side); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; margin: 0 0 1.2em; overflow-x: auto; color: var(--text); tab-size: 2; }
|
||||
.lr pre.code code { font-size: inherit; padding: 0; background: none; }
|
||||
.lr .prose kbd { font: 500 .7em/1 var(--mono); padding: 2px 5px; border: 1px solid var(--line2); border-radius: 4px; }
|
||||
|
||||
/* folds and hint tiers */
|
||||
.lr details.fold { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); margin: 0 0 1.4em; }
|
||||
.lr details.fold + details.fold { margin-top: -1.4em; border-top: 0; }
|
||||
.lr details.fold > summary { list-style: none; display: flex; align-items: center; gap: 10px; padding: 12px 2px; font: 500 14px/1.4 var(--sans); color: var(--text); cursor: pointer; }
|
||||
.lr details.fold > summary::-webkit-details-marker { display: none; }
|
||||
.lr details.fold > summary svg { color: var(--faint); transition: transform var(--fast); flex: none; }
|
||||
.lr details.fold[open] > summary svg { transform: rotate(90deg); }
|
||||
.lr details.fold > summary:hover { color: var(--head); }
|
||||
.lr details.fold > summary .tier { margin-left: auto; font-size: 12px; color: var(--faint); font-weight: 400; white-space: nowrap; }
|
||||
.lr details.fold[data-wait] > summary { color: var(--faint); cursor: default; }
|
||||
.lr details.fold > .in { padding: 2px 0 14px 24px; }
|
||||
.lr .hints .fold > .in { font-size: .95em; }
|
||||
|
||||
/* ---------- pills ---------- */
|
||||
.lr .stc-pill.sm { height: 28px; padding: 0 11px; font-size: 12.5px; }
|
||||
.lr a.stc-pill { color: inherit; }
|
||||
.lr a.stc-pill--primary { color: var(--on-accent); }
|
||||
|
||||
/* ---------- floating action bar ---------- */
|
||||
.lr .stc-dock [aria-pressed="true"] { color: var(--accent); }
|
||||
.lr .stc-dock .sep { width: 1px; background: var(--line2); margin: 7px 2px; }
|
||||
.lr .stc-dock a.stc-pill[aria-disabled="true"] { opacity: .45; pointer-events: none; }
|
||||
|
||||
/* ---------- video ---------- */
|
||||
.lr .player { aspect-ratio: 16 / 9; background: #000; border-radius: 10px; overflow: hidden; margin-top: 22px; }
|
||||
.lr .player video { display: block; width: 100%; height: 100%; }
|
||||
.lr .split { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 40px; margin-top: 28px; }
|
||||
.lr .note-ta { width: 100%; min-height: 110px; resize: vertical; background: var(--side); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; color: var(--text); font-size: 14px; line-height: 1.55; display: block; }
|
||||
.lr .note-ta:focus { outline: 0; border-color: var(--faint); }
|
||||
.lr .note-actions { display: flex; gap: 6px; justify-content: flex-end; margin-top: 8px; align-items: center; }
|
||||
.lr .note-actions .msg { margin-right: auto; font-size: 12px; color: var(--faint); }
|
||||
.lr .note-list { list-style: none; margin: 18px 0 0; padding: 0; border-top: 1px solid var(--line); }
|
||||
.lr .note-list > li { padding: 12px 2px; border-bottom: 1px solid var(--line); font-size: 14px; line-height: 1.55; }
|
||||
.lr .note-list .nb { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.lr .note-list .nm { display: flex; gap: 10px; align-items: center; margin-top: 6px; font-size: 12px; color: var(--faint); }
|
||||
.lr .note-list .nm button { border: 0; background: none; padding: 0; color: var(--faint); font-size: 12px; }
|
||||
.lr .note-list .nm button:hover { color: var(--text); }
|
||||
.lr .stamp { border: 0; background: none; padding: 0 2px; color: var(--accent); font: inherit; font-variant-numeric: tabular-nums; text-decoration: underline; text-decoration-color: var(--accent-dim); text-underline-offset: 3px; }
|
||||
.lr .transcript { margin-top: 22px; }
|
||||
|
||||
/* rows (attachments) */
|
||||
.lr .rows { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
|
||||
.lr .rows li { display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 2px; border-bottom: 1px solid var(--line); font-size: 13.5px; }
|
||||
.lr .rows li svg { color: var(--faint); }
|
||||
.lr .rows a { overflow-wrap: anywhere; text-decoration: none; }
|
||||
.lr .rows a:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||
.lr .rows .sz { white-space: nowrap; font-size: 12px; color: var(--faint); }
|
||||
|
||||
/* ---------- slides ---------- */
|
||||
.lr .deck { margin-top: 28px; }
|
||||
.lr .stage { position: relative; aspect-ratio: 16 / 9; background: var(--side); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; display: grid; place-items: center; margin: 0; }
|
||||
.lr .stage canvas { display: block; max-width: 100%; max-height: 100%; }
|
||||
.lr .stage .msg { color: var(--faint); font-size: 13px; padding: 20px; text-align: center; }
|
||||
.lr .stage:fullscreen { border-radius: 0; background: #000; border: 0; }
|
||||
.lr .deckbar { display: flex; align-items: center; gap: 8px; margin: 12px 0 0; flex-wrap: wrap; }
|
||||
.lr .deckbar .count { font-size: 13px; color: var(--muted); margin: 0 6px; min-width: 58px; text-align: center; }
|
||||
.lr .deckbar .gap { flex: 1; }
|
||||
.lr .deckbar .hint { font-size: 12.5px; color: var(--faint); }
|
||||
.lr .thumbs { display: flex; gap: 8px; margin: 16px 0 0; overflow-x: auto; padding: 3px 3px 6px; }
|
||||
.lr .thumbs button { flex: none; width: 104px; aspect-ratio: 16 / 9; padding: 0; border: 1px solid var(--line2); border-radius: 5px; overflow: hidden; background: var(--side); opacity: .6; transition: opacity var(--fast); display: grid; place-items: center; color: var(--faint); font-size: 11px; position: relative; }
|
||||
.lr .thumbs button:hover { opacity: .9; }
|
||||
.lr .thumbs button[aria-current="true"] { opacity: 1; outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.lr .thumbs canvas { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||
.lr .thumbs .has-note::after { content: ""; position: absolute; top: 4px; right: 4px; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
|
||||
.lr .slide-note { margin-top: 16px; }
|
||||
.lr .slide-note label { display: block; font-size: 12px; color: var(--faint); margin-bottom: 6px; }
|
||||
.lr .slide-note .note-ta { min-height: 72px; }
|
||||
|
||||
/* ---------- code files ---------- */
|
||||
.lr .files { display: grid; grid-template-columns: 220px minmax(0, 1fr); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; margin: 0 0 1.4em; font-family: var(--sans); }
|
||||
.lr .tree { background: var(--side); border-right: 1px solid var(--line); padding: 8px 0; font-size: 13px; max-height: 520px; overflow: auto; }
|
||||
.lr .tree .dir { padding: 5px 12px; color: var(--muted); display: flex; gap: 7px; align-items: center; white-space: nowrap; }
|
||||
.lr .tree button { display: flex; gap: 7px; align-items: center; width: 100%; border: 0; background: none; text-align: left; padding: 5px 12px; color: var(--muted); font-size: 13px; white-space: nowrap; }
|
||||
.lr .tree button:hover { color: var(--text); }
|
||||
.lr .tree button[aria-current="true"] { background: var(--raised); color: var(--head); }
|
||||
.lr .tree svg { flex: none; color: var(--faint); }
|
||||
.lr .viewer { min-width: 0; display: flex; flex-direction: column; background: var(--ground); }
|
||||
.lr .tabs { display: flex; align-items: center; border-bottom: 1px solid var(--line); padding: 0 6px 0 0; overflow-x: auto; min-height: 40px; }
|
||||
.lr .tabs [role="tab"] { border: 0; background: none; padding: 10px 14px; font-size: 12.5px; color: var(--faint); border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; font-family: var(--mono); }
|
||||
.lr .tabs [role="tab"][aria-selected="true"] { color: var(--head); border-bottom-color: var(--accent); }
|
||||
.lr .tabs .stc-pill { margin-left: auto; }
|
||||
.lr .path { font-size: 12px; color: var(--faint); padding: 8px 16px 0; overflow-wrap: anywhere; }
|
||||
.lr .viewer pre.code { border: 0; border-radius: 0; background: none; margin: 0; counter-reset: ln; max-height: 520px; overflow: auto; }
|
||||
.lr .ln { display: block; counter-increment: ln; }
|
||||
.lr .ln::before { content: counter(ln); display: inline-block; width: 2.2em; margin-right: 1em; text-align: right; color: var(--faint); opacity: .6; }
|
||||
.lr .cmd { position: relative; }
|
||||
.lr .cmd pre.code { padding-right: 80px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.lr .cmd .stc-pill { position: absolute; top: 8px; right: 8px; }
|
||||
|
||||
/* ---------- exercise ---------- */
|
||||
.lr .ex { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: 36px; align-items: start; }
|
||||
.lr .ex .prose { font-size: 15px; }
|
||||
.lr .edit { border: 1px solid var(--line); border-radius: 10px; overflow: hidden; position: sticky; top: calc(var(--top) + 20px); }
|
||||
.lr .edit-bar { display: flex; align-items: center; gap: 6px; padding: 6px 6px 6px 14px; background: var(--side); border-bottom: 1px solid var(--line); }
|
||||
.lr .edit-bar .f { font: 500 12.5px var(--mono); color: var(--muted); margin-right: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.lr .editor { display: grid; grid-template-columns: 44px 1fr; background: var(--ground); height: 300px; }
|
||||
.lr .gutter { margin: 0; padding: 12px 10px 12px 0; text-align: right; color: var(--faint); opacity: .6; font: 13.5px/1.6 var(--mono); overflow: hidden; user-select: none; }
|
||||
.lr .editor textarea { border: 0; resize: none; outline: 0; background: none; padding: 12px 14px 12px 6px; font: 13.5px/1.6 var(--mono); color: var(--text); white-space: pre; overflow: auto; tab-size: 2; }
|
||||
.lr .editor:focus-within { box-shadow: inset 0 0 0 1px var(--faint); }
|
||||
.lr .results { border-top: 1px solid var(--line); background: var(--side); }
|
||||
.lr .res-sum { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 14px; font-size: 13px; color: var(--muted); }
|
||||
.lr .res-sum b { font-weight: 600; color: var(--head); }
|
||||
.lr .res-list { list-style: none; margin: 0; padding: 0; max-height: 300px; overflow: auto; border-top: 1px solid var(--line); }
|
||||
.lr .res-list li { padding: 8px 14px; border-bottom: 1px solid var(--line); font-size: 13px; }
|
||||
.lr .res-list li:last-child { border-bottom: 0; }
|
||||
.lr .res-row { display: grid; grid-template-columns: 40px 1fr; gap: 8px; }
|
||||
.lr .res-row .s { font-weight: 600; font-size: 12px; }
|
||||
.lr .pass .s { color: var(--ok); }
|
||||
.lr .fail .s { color: var(--bad); }
|
||||
.lr .res-row code { font-size: 12.5px; color: var(--text); }
|
||||
.lr .diff { display: grid; grid-template-columns: 64px 1fr; gap: 2px 8px; margin: 6px 0 2px 48px; font-size: 12.5px; }
|
||||
.lr .diff dt { color: var(--faint); }
|
||||
.lr .diff dd { margin: 0; font-family: var(--mono); color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.lr .local { margin: 20px 0 0; padding: 12px 0 0; border-top: 1px solid var(--line); font-size: 13px; color: var(--muted); }
|
||||
.lr .local strong { color: var(--text); font-weight: 500; }
|
||||
.lr .local code { display: block; margin-top: 6px; font-size: 12.5px; color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.lr .local-panel { border: 1px solid var(--line); border-radius: 10px; padding: 16px; background: var(--side); }
|
||||
.lr .local-panel h2 { margin: 0 0 6px; font: 600 15px/1.3 var(--sans); color: var(--head); }
|
||||
.lr .local-panel p { margin: 0 0 12px; font-size: 13.5px; color: var(--muted); }
|
||||
.lr .local-panel .cmd pre.code { background: var(--ground); }
|
||||
.lr .local-panel textarea { min-height: 90px; font-family: var(--mono); font-size: 12.5px; }
|
||||
|
||||
/* ---------- lock, empty, error ---------- */
|
||||
.lr .notice { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 18px 2px; margin-top: 28px; color: var(--muted); font-size: 14px; }
|
||||
.lr .notice strong { color: var(--head); font-weight: 500; }
|
||||
.lr .notice .stc-pill { margin-top: 12px; }
|
||||
|
||||
/* ---------- library ---------- */
|
||||
.lr-lib { max-width: 1080px; margin: 0 auto; padding-bottom: 60px; }
|
||||
.lr .lib-h { font: 600 24px/1.2 var(--sans); color: var(--head); margin: 0 0 4px; }
|
||||
.lr .lib-sub { color: var(--faint); margin: 0 0 32px; font-size: 13.5px; }
|
||||
.lr .feat { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 28px; align-items: end; padding: 0 0 32px; border-bottom: 1px solid var(--line); margin-bottom: 36px; }
|
||||
.lr a:has(> .stc-cover), .lr a .stc-cover { text-decoration: none; }
|
||||
.lr .feat .k { font-size: 12.5px; color: var(--faint); margin: 0 0 8px; }
|
||||
.lr .feat h2 { margin: 0; font: 600 30px/1.15 var(--sans); color: var(--head); letter-spacing: -.01em; text-wrap: balance; }
|
||||
.lr .feat h2 a { text-decoration: none; }
|
||||
.lr .feat p.pitch { margin: 10px 0 0; font-size: 15px; color: var(--muted); max-width: 560px; }
|
||||
.lr .feat .facts { display: flex; flex-wrap: wrap; gap: 6px 18px; margin: 14px 0 18px; font-size: 12.5px; color: var(--faint); }
|
||||
.lr .feat .actions { display: flex; gap: 8px; align-items: center; }
|
||||
.lr .continue { display: grid; grid-template-columns: auto 1fr auto; gap: 20px; align-items: center; padding: 18px 0; border-bottom: 1px solid var(--line); }
|
||||
.lr .continue-list { border-top: 1px solid var(--line); margin-bottom: 44px; }
|
||||
.lr .continue .t { font-size: 16px; font-weight: 600; color: var(--head); }
|
||||
.lr .continue .s { font-size: 13.5px; color: var(--muted); margin: 2px 0 10px; }
|
||||
.lr .continue .lr-prog { max-width: 360px; margin: 0; }
|
||||
.lr .filters { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center; margin: 0 0 30px; }
|
||||
.lr .filters .lbl { font-size: 12px; color: var(--faint); margin-right: -6px; }
|
||||
.lr .lib-row-h { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
||||
.lr .strip-wrap { margin-bottom: 40px; }
|
||||
.lr .strip { display: flex; gap: 22px; overflow-x: auto; padding: 4px 2px 12px; scroll-snap-type: x proximity; }
|
||||
.lr .tile { flex: none; width: 150px; text-decoration: none; scroll-snap-align: start; }
|
||||
.lr .tile .stc-cover { --cw: 150px; }
|
||||
.lr .tile .tt { display: block; margin-top: 10px; font-size: 13.5px; font-weight: 600; color: var(--text); line-height: 1.3; }
|
||||
.lr .tile:hover .tt { color: var(--head); text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--line2); }
|
||||
.lr .tile .ts { display: block; font-size: 12px; color: var(--faint); margin-top: 2px; }
|
||||
.lr .tile .stc-bar { margin-top: 8px; }
|
||||
.lr .lib { border-top: 1px solid var(--line); }
|
||||
.lr .lib-row, .lr .lib-cols { display: grid; grid-template-columns: minmax(0, 1fr) 90px 210px 180px; gap: 24px; align-items: center; }
|
||||
.lr .lib-cols { padding: 0 0 8px; font-size: 12px; color: var(--faint); }
|
||||
.lr .lib-row { padding: 16px 0; border-bottom: 1px solid var(--line); text-decoration: none; }
|
||||
.lr .lib-row:hover .lt { color: var(--head); text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--line2); }
|
||||
.lr .lib-main { display: flex; gap: 16px; align-items: center; min-width: 0; }
|
||||
.lr .lt { font-size: 15px; font-weight: 600; color: var(--text); }
|
||||
.lr .ls { display: block; font-size: 13px; color: var(--faint); margin-top: 2px; }
|
||||
.lr .lnum { font-size: 13.5px; color: var(--muted); }
|
||||
.lr .tags { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.lr .tag { font-size: 11.5px; color: var(--muted); border: 1px solid var(--line2); border-radius: 10px; padding: 1px 8px; }
|
||||
.lr .tag.bad { color: var(--bad); }
|
||||
.lr .lib-row .lr-prog { margin: 0; }
|
||||
.lr .hits { list-style: none; margin: 0 0 36px; padding: 0; border-top: 1px solid var(--line); }
|
||||
.lr .hits li a { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 10px 2px; border-bottom: 1px solid var(--line); text-decoration: none; font-size: 13.5px; }
|
||||
.lr .hits li a:hover .h1 { color: var(--head); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.lr .hits .h2 { font-size: 12px; color: var(--faint); }
|
||||
|
||||
/* ---------- notes page, account ---------- */
|
||||
.lr .notes-group { margin-top: 30px; }
|
||||
.lr .notes-group h2 { font: 600 15px/1.3 var(--sans); margin: 0 0 6px; }
|
||||
.lr .notes-group h2 a { color: var(--head); text-decoration: none; }
|
||||
.lr .notes-group h2 a:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||
.lr .notes-group .note-list { margin-top: 8px; }
|
||||
.lr .anchor { font-size: 12px; color: var(--muted); text-decoration: none; border: 1px solid var(--line2); border-radius: 10px; padding: 1px 8px; }
|
||||
.lr .anchor:hover { color: var(--head); }
|
||||
|
||||
.lr .form { display: grid; gap: 14px; max-width: 380px; }
|
||||
.lr .form label { display: grid; gap: 5px; font-size: 12.5px; color: var(--muted); }
|
||||
.lr .form input, .lr .field { height: 38px; padding: 0 12px; border: 1px solid var(--line2); border-radius: 10px; background: var(--side); color: var(--text); font-size: 14px; }
|
||||
.lr .form input:focus, .lr .field:focus { outline: 0; border-color: var(--faint); }
|
||||
.lr select.field { padding-right: 8px; }
|
||||
.lr .form .err, .lr .err { color: var(--bad); font-size: 13px; margin: 0; }
|
||||
.lr .ok-msg { color: var(--ok); font-size: 13px; margin: 0; }
|
||||
.lr .or { display: flex; align-items: center; gap: 12px; color: var(--faint); font-size: 12px; margin: 26px 0 16px; max-width: 380px; }
|
||||
.lr .or::before, .lr .or::after { content: ""; flex: 1; border-top: 1px solid var(--line); }
|
||||
.lr .prov { display: flex; flex-wrap: wrap; gap: 8px; max-width: 380px; }
|
||||
.lr .acct-dl { display: grid; grid-template-columns: 90px 1fr; gap: 8px 16px; margin: 22px 0; font-size: 14px; }
|
||||
.lr .acct-dl dt { color: var(--faint); }
|
||||
.lr .acct-dl dd { margin: 0; }
|
||||
.lr .admin-sec { margin-top: 44px; }
|
||||
.lr .admin-sec > h2 { font: 600 16px/1.3 var(--sans); color: var(--head); margin: 0 0 12px; }
|
||||
.lr .table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
||||
.lr .table th { text-align: left; font-weight: 500; font-size: 12px; color: var(--faint); padding: 0 12px 8px 0; border-bottom: 1px solid var(--line); }
|
||||
.lr .table td { padding: 10px 12px 10px 0; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
||||
.lr .table td .ls { margin: 0; }
|
||||
.lr .checks { display: flex; flex-wrap: wrap; gap: 6px 16px; }
|
||||
.lr .checks label { display: inline-flex; gap: 6px; align-items: center; font-size: 13px; color: var(--muted); }
|
||||
.lr input[type="checkbox"] { accent-color: var(--accent); }
|
||||
.lr .row-form { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 12px 0 0; }
|
||||
|
||||
.lr-toast { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 60; background: var(--raised); border: 1px solid var(--line2); border-radius: 18px; padding: 9px 16px; font-size: 13px; color: var(--text); }
|
||||
|
||||
/* ---------- responsive ---------- */
|
||||
@media (max-width: 1100px) {
|
||||
.lr .ex { grid-template-columns: minmax(0, 1fr); }
|
||||
.lr .edit { position: static; }
|
||||
.lr .split { grid-template-columns: 1fr; gap: 28px; }
|
||||
.lr .lib-row, .lr .lib-cols { grid-template-columns: minmax(0, 1fr) 70px 150px; }
|
||||
.lr .lib-row > .tags, .lr .lib-cols > :nth-child(3) { display: none; }
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.stc-top > a:first-child { flex: none; }
|
||||
.lr-icon { display: inline-grid; }
|
||||
.lr-side { position: fixed; top: var(--top); left: 0; bottom: 0; height: auto; z-index: 40; transform: translateX(-100%); transition: transform 160ms ease-out; }
|
||||
.lr-shell.side-open .lr-side { transform: none; }
|
||||
.lr-shell.side-open .lr-scrim { display: block; position: fixed; inset: var(--top) 0 0 0; z-index: 35; background: rgb(10 11 15 / .6); }
|
||||
.lr-main { padding: 32px 24px 0; }
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
.stc-top { gap: 8px; padding: 0 10px; }
|
||||
.stc-top > a:first-child { display: none; }
|
||||
.lr-search { display: none; }
|
||||
.lr-switch-btn { max-width: 170px; }
|
||||
.lr-switch-btn .t { font-size: 12.5px; }
|
||||
.lr .stc-seg button { padding: 0 10px; }
|
||||
.lr-main { padding: 26px 18px 0; }
|
||||
.lr[data-look="reading"], .lr [data-look="reading"] { --ps: 18px; --h1: 31px; }
|
||||
.lr .meta { gap: 12px 22px; }
|
||||
.lr .files { grid-template-columns: 1fr; }
|
||||
.lr .tree { border-right: 0; border-bottom: 1px solid var(--line); max-height: 200px; }
|
||||
.lr .continue { grid-template-columns: auto 1fr; }
|
||||
.lr .continue .stc-pill { grid-column: 1 / -1; justify-self: start; }
|
||||
.lr .feat { grid-template-columns: 1fr; }
|
||||
.lr .lib-row, .lr .lib-cols { grid-template-columns: minmax(0, 1fr) 92px; gap: 14px; }
|
||||
.lr .lib-row > .lnum, .lr .lib-cols > :nth-child(2) { display: none; }
|
||||
.lr-menu { width: min(330px, calc(100vw - 20px)); left: -48px; }
|
||||
.lr .stc-dock { bottom: 12px; }
|
||||
.lr .stc-dock .lbl-long { display: none; }
|
||||
.lr .thumbs button { width: 84px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lr *, .lr *::before, .lr *::after, .lr-side { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { SignInForms } from "../src/web/account/AccountPage";
|
||||
import AdminPage from "../src/web/account/AdminPage";
|
||||
|
||||
test("the sign-in page has email and password fields and a create-account switch", () => {
|
||||
const out = renderToString(<MemoryRouter initialEntries={["/account?next=/c/sample"]}><SignInForms /></MemoryRouter>);
|
||||
expect(out).toContain('type="email"');
|
||||
expect(out).toContain('type="password"');
|
||||
expect(out).toContain('autoComplete="current-password"');
|
||||
expect(out).toContain("Create account");
|
||||
});
|
||||
|
||||
test("the admin page renders its frame before the session is known", () => {
|
||||
const out = renderToString(<MemoryRouter><AdminPage /></MemoryRouter>);
|
||||
expect(out).toContain("Users, groups and library");
|
||||
expect(out).not.toContain("Users and roles"); // owner-only sections wait for the session
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { expect } from "bun:test";
|
||||
import { forEachDb } from "./helpers";
|
||||
import { makeApp } from "./app";
|
||||
|
||||
forEachDb("the pdf.js worker is served for client-side deck rendering", async (db) => {
|
||||
const { call } = await makeApp(db);
|
||||
const r = await call("GET", "/api/learner/pdf.worker.mjs");
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.headers.get("content-type")).toContain("javascript");
|
||||
expect((await r.text()).length).toBeGreaterThan(100_000);
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { connect } from "@stc/db";
|
||||
import { LessonView } from "../src/web";
|
||||
import { LessonView as FromIndex } from "../src/index";
|
||||
import type { LessonPayload } from "../src/shared/types";
|
||||
import { localCommandFor } from "../src/web/lesson/Exercise";
|
||||
import { firstStamp } from "../src/web/lesson/notes";
|
||||
import { buildTree } from "../src/web/lesson/CodeFiles";
|
||||
import { runMigrations } from "../src/server/migrate";
|
||||
import { makeApp } from "./app";
|
||||
|
||||
async function sampleLessons(ids: string[]): Promise<Record<string, LessonPayload>> {
|
||||
const db = await connect("sqlite://:memory:");
|
||||
await runMigrations(db);
|
||||
const { call } = await makeApp(db);
|
||||
const out: Record<string, LessonPayload> = {};
|
||||
for (const id of ids) out[id] = (await (await call("GET", `/api/courses/sample/lessons/${id}`)).json()) as LessonPayload;
|
||||
await db.close();
|
||||
return out;
|
||||
}
|
||||
|
||||
const html = (el: React.ReactElement) => renderToString(<MemoryRouter>{el}</MemoryRouter>);
|
||||
|
||||
test("LessonView is exported from the package index and the web entry", () => {
|
||||
expect(FromIndex).toBe(LessonView);
|
||||
});
|
||||
|
||||
test("LessonView renders every sample lesson type", async () => {
|
||||
const l = await sampleLessons(["m1-intro", "m1-video", "m2-ex-add"]);
|
||||
|
||||
const text = html(<LessonView lesson={l["m1-intro"]!} />);
|
||||
expect(text).toContain('data-type="text"');
|
||||
expect(text).toContain('data-look="reading"');
|
||||
expect(text).toContain("<h1 class=\"title\">Introduction</h1>");
|
||||
expect(text).toContain("Welcome to the sample course");
|
||||
expect(text).toContain("Files for this section"); // module m1 has companion code
|
||||
expect(text).toContain("/api/courses/sample/code/m1/zip");
|
||||
|
||||
const video = html(<LessonView lesson={l["m1-video"]!} signedIn={false} />);
|
||||
expect(video).toContain('data-type="video"');
|
||||
expect(video).toMatch(/<video[^>]+src="\/api\/media\/sample\/media\/clip\.mp4"/);
|
||||
expect(video).toContain('data-deck="d1"'); // companion deck
|
||||
expect(video).toContain("Transcript");
|
||||
expect(video).toContain("Sign in"); // notes need an account
|
||||
|
||||
const ex = html(<LessonView lesson={l["m2-ex-add"]!} look="reading" signedIn />);
|
||||
expect(ex).toContain('data-look="work"'); // exercises always use Work
|
||||
expect(ex).toContain("Run tests");
|
||||
expect(ex).toContain("return a - b;"); // the starter is in the editor
|
||||
expect(ex).toContain('data-tier="1"');
|
||||
expect(ex).toContain('data-tiers="2"');
|
||||
expect(ex).toContain("node --test");
|
||||
expect(ex).not.toContain("return a + b"); // no solution for learners
|
||||
});
|
||||
|
||||
test("preview mode renders without notes or saved-attempt UI", async () => {
|
||||
const l = await sampleLessons(["m1-video"]);
|
||||
const out = html(<LessonView lesson={l["m1-video"]!} preview />);
|
||||
expect(out).not.toContain("Your notes");
|
||||
expect(out).not.toContain("Insert time");
|
||||
});
|
||||
|
||||
test("a locked lesson shows the lock notice and no content", () => {
|
||||
const locked: LessonPayload = {
|
||||
courseId: "c", courseTitle: "C", lesson: { id: "l2", title: "Later", type: "text", minutes: null },
|
||||
module: { id: "m2", title: "Two", index: 1 }, locked: true, markdown: null, baseFile: null, trackMarkdown: {}, tracks: [],
|
||||
video: null, transcript: null, hints: [], decks: [], code: null, attachments: [], browser: null, localCommand: null,
|
||||
prev: { id: "l1", title: "Earlier" }, next: null, lessonFiles: {},
|
||||
};
|
||||
const out = html(<LessonView lesson={locked} signedIn />);
|
||||
expect(out).toContain("This module is locked.");
|
||||
expect(out).toMatch(/Back to (<!-- -->)?Earlier/);
|
||||
});
|
||||
|
||||
test("track variants: the chosen track's markdown and code are shown", () => {
|
||||
const lesson: LessonPayload = {
|
||||
courseId: "c", courseTitle: "C", lesson: { id: "t", title: "Tracks `x`", type: "text", minutes: 5 },
|
||||
module: { id: "m", title: "M", index: 0 }, locked: false,
|
||||
markdown: "# T\n\n**JavaScript**\n\n```js\nconst a = 1\n```\n\n**Scala 3**\n\n```scala\nval a = 1\n```\n",
|
||||
baseFile: "course/t.md", trackMarkdown: {}, tracks: [{ id: "js", label: "JavaScript" }, { id: "scala", label: "Scala 3" }],
|
||||
video: null, transcript: null, hints: [], decks: [], code: null, attachments: [], browser: null,
|
||||
localCommand: { js: "npm test", scala: "sbt test" }, prev: null, next: null, lessonFiles: {},
|
||||
};
|
||||
const js = html(<LessonView lesson={lesson} track="js" />);
|
||||
expect(js).toContain("const a = 1");
|
||||
expect(js).not.toContain("val a = 1");
|
||||
expect(js).toContain("<code>x</code>"); // inline code in the title
|
||||
expect(js).toContain('aria-label="Track"');
|
||||
expect(js).toContain("npm test");
|
||||
const scala = html(<LessonView lesson={lesson} track="scala" />);
|
||||
expect(scala).toContain("val a = 1");
|
||||
expect(scala).toContain("sbt test");
|
||||
});
|
||||
|
||||
test("helpers: local command per track, note timestamps, code tree", () => {
|
||||
expect(localCommandFor({ js: "a", scala: "b" }, "scala")).toBe("b");
|
||||
expect(localCommandFor({ js: "a" }, "fsharp")).toBe("a");
|
||||
expect(localCommandFor("x", null)).toBe("x");
|
||||
expect(firstStamp("see [1:05] and [0:10]")).toBe(65);
|
||||
expect(firstStamp("[1:02:03] x")).toBe(3723);
|
||||
expect(firstStamp("no stamp")).toBeNull();
|
||||
const t = buildTree([{ path: "a.txt", size: 1 }, { path: "src/b.ts", size: 2 }, { path: "src/x/c.ts", size: 3 }]);
|
||||
expect(t.files.map((f) => f.name)).toEqual(["a.txt"]);
|
||||
expect(t.dirs.get("src")!.files.map((f) => f.path)).toEqual(["src/b.ts"]);
|
||||
expect(t.dirs.get("src")!.dirs.get("x")!.files[0]!.path).toBe("src/x/c.ts");
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { learnerWeb } from "../src/web";
|
||||
import { filterCourses, LibraryView } from "../src/web/library/LibraryPage";
|
||||
import { CourseFrame, moduleNumber } from "../src/web/shell/CourseFrame";
|
||||
import { nextLesson } from "../src/web/course/pages";
|
||||
import { safeNext } from "../src/web/account/AccountPage";
|
||||
import type { CourseCard, CoursePayload, LibraryPayload } from "../src/shared/types";
|
||||
|
||||
const card = (id: string, over: Partial<CourseCard> = {}): CourseCard => ({
|
||||
id, title: id.toUpperCase(), pitch: `${id} pitch`, authors: [], coverUrl: null, accent: null, formats: ["text"], lessonCount: 3,
|
||||
minutes: 30, collections: [], tracks: [], source: "authored", addedAt: "2026-10-01T00:00:00Z", status: "ok", progress: 0, doneCount: 0, started: false, ...over,
|
||||
});
|
||||
|
||||
const lib: LibraryPayload = {
|
||||
signedIn: true,
|
||||
featured: card("snake", { formats: ["text", "exercises"], minutes: 900 }),
|
||||
continue: [{ courseId: "snake", lessonId: "m3", lessonTitle: "Pure game rules", moduleTitle: "Pure game rules", moduleIndex: 3, moduleDone: 1, moduleTotal: 5, progress: 0.2 }],
|
||||
newest: ["docker", "snake"],
|
||||
collections: [{ id: "devops", title: "DevOps", courseIds: ["docker"] }],
|
||||
courses: [card("docker", { formats: ["video", "slides"], minutes: 300, started: true, progress: 0.5 }), card("snake", { formats: ["text", "exercises"], minutes: 900 })],
|
||||
};
|
||||
|
||||
test("the learner web feature registers its routes", () => {
|
||||
expect(learnerWeb.routes.map((r) => r.path)).toEqual(["/", "/c/:courseId", "/c/:courseId/notes", "/c/:courseId/:lessonId", "/account", "/account/admin"]);
|
||||
});
|
||||
|
||||
test("every route module loads and has a default component", async () => {
|
||||
for (const r of learnerWeb.routes) expect(typeof (await r.element()).default).toBe("function");
|
||||
});
|
||||
|
||||
test("library: featured course, continue row, browsable rows and the course list", () => {
|
||||
const out = renderToString(<MemoryRouter><LibraryView lib={lib} /></MemoryRouter>);
|
||||
expect(out).toContain("Featured");
|
||||
expect(out).toContain("SNAKE");
|
||||
expect(out).toContain("Next up: <!-- -->Pure game rules");
|
||||
expect(out).toContain("New in the library");
|
||||
expect(out).toContain("DevOps");
|
||||
expect(out).toContain("All courses");
|
||||
expect(out).toContain('href="/c/snake/m3"');
|
||||
expect(out).not.toMatch(/price|rating|bestseller/i);
|
||||
});
|
||||
|
||||
test("library: search and filters switch to a results list", () => {
|
||||
const out = renderToString(<MemoryRouter><LibraryView lib={lib} initialQuery="dock" /></MemoryRouter>);
|
||||
expect(out).toContain("Courses matching “dock”");
|
||||
expect(out).not.toContain("New in the library");
|
||||
});
|
||||
|
||||
test("library filters by format, length and in-progress", () => {
|
||||
const cs = lib.courses;
|
||||
expect(filterCourses(cs, { format: "video", length: "any", inProgress: false }).map((c) => c.id)).toEqual(["docker"]);
|
||||
expect(filterCourses(cs, { format: "all", length: "long", inProgress: false }).map((c) => c.id)).toEqual(["snake"]);
|
||||
expect(filterCourses(cs, { format: "all", length: "medium", inProgress: false }).map((c) => c.id)).toEqual(["docker"]);
|
||||
expect(filterCourses(cs, { format: "all", length: "any", inProgress: true }).map((c) => c.id)).toEqual(["docker"]);
|
||||
});
|
||||
|
||||
const course: CoursePayload = {
|
||||
id: "sd", title: "Systemic Design", pitch: null, authors: [], accent: "#86c7a6", coverUrl: null,
|
||||
tracks: [{ id: "rust", label: "Rust" }, { id: "ts", label: "TypeScript" }], gating: "sequential", source: "authored", status: "ok",
|
||||
modules: [
|
||||
{ id: "m00", title: "Intro", index: 0, locked: false, hasCode: false, lessons: [{ id: "a", title: "A", type: "text", minutes: 5, hasDeck: false, browser: false }] },
|
||||
{ id: "m01", title: "Next", index: 1, locked: true, hasCode: false, lessons: [{ id: "b", title: "Exercise 1.1 — `move`", type: "exercise", minutes: 15, hasDeck: false, browser: false }] },
|
||||
],
|
||||
progress: { a: { state: "done", positionSeconds: null, updatedAt: "" } }, canEdit: false, signedIn: true, diagnostics: [],
|
||||
};
|
||||
|
||||
test("course frame: outline with progress, locks, current lesson and course accent", () => {
|
||||
const out = renderToString(
|
||||
<MemoryRouter initialEntries={["/c/sd/a"]}>
|
||||
<CourseFrame course={course} currentLessonId="a" look="reading" lookMode="toggle"><p>body</p></CourseFrame>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(out).toContain("--accent:#86c7a6");
|
||||
expect(out).toContain('aria-current="page"');
|
||||
expect(out).toContain("lr-les done");
|
||||
expect(out).toContain("(locked)");
|
||||
expect(out).toContain('aria-label="Course progress"');
|
||||
expect(out).toContain("Your notes");
|
||||
});
|
||||
|
||||
test("module numbers count from 0 when the first module id ends in 0", () => {
|
||||
expect(moduleNumber(course, 1)).toBe(1);
|
||||
expect(moduleNumber({ modules: [{ id: "m1" }] }, 0)).toBe(1);
|
||||
});
|
||||
|
||||
test("next lesson skips done lessons and stops at a locked module", () => {
|
||||
expect(nextLesson(course)).toEqual(course.modules[0]!.lessons[0]!);
|
||||
const open = { ...course, modules: course.modules.map((m) => ({ ...m, locked: false })) };
|
||||
expect(nextLesson(open)!.id).toBe("b");
|
||||
});
|
||||
|
||||
test("sign-in only returns to same-site paths", () => {
|
||||
expect(safeNext("/c/x")).toBe("/c/x");
|
||||
expect(safeNext("//evil.example")).toBe("/");
|
||||
expect(safeNext("https://evil.example")).toBe("/");
|
||||
expect(safeNext(null)).toBe("/");
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
// Browser entry "@stc/feature-learner/web". The repo maps "@stc/*" to "packages/*/src", so this subpath
|
||||
// resolves here (tsc and Bun alike). It re-exports the web half only: the package index also carries the
|
||||
// server (Better Auth, SSO), which cannot be bundled for the browser.
|
||||
export * from "../../src/web";
|
||||
Reference in New Issue
Block a user