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 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 04:53:36 +01:00
1 parent 99c0e4929f
commit 89c51f013d
27 files changed
+2994 -1

No files matched your search

@@ -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>;
}