Files
STCourses/packages/feature-learner/src/web/course/pages.tsx
T
Lucas WintherandClaude Opus 5.5 b5e00af4f7 fix(web,learner): navigation swaps at once; content-only entrance, loading line, focus (R19)
Drops document-level View Transitions and the lesson hold: they dropped clicks for 120-180 ms, froze the screen
while route code loaded, and let the dock act on a stale lesson. Navigation now swaps immediately; only the
content region enters (opacity and a 6 px rise, 160 ms); a 2 px loading line appears when code or data takes over
150 ms; the dock works from the outline and the URL, so a second Next advances again; focus moves to the new h1
and a polite live region announces it. Route chunks of a feature preload together so the sidebar never drops.
Scroll restore stops on user input; pdf.js never starts a load after cleanup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:49:49 +01:00

244 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, type CSSProperties } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { CourseCover, FloatingActionBar, LoadingLine, Pill, ProgressBar } from "@stc/ui";
import type { CoursePayload, LessonPayload, Note, OutlineLesson, 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 <LoadingLine />;
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 ---------- */
/** The lessons before and after `lessonId` in course order (the same flat order the lesson API uses). */
export function lessonNeighbours(c: CoursePayload, lessonId: string): { current: OutlineLesson | null; prev: OutlineLesson | null; next: OutlineLesson | null } {
const flat = c.modules.flatMap((m) => m.lessons);
const i = flat.findIndex((l) => l.id === lessonId);
return i < 0 ? { current: null, prev: null, next: null } : { current: flat[i]!, prev: flat[i - 1] ?? null, next: flat[i + 1] ?? null };
}
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)}`);
// Only the lesson the URL names is ever shown: while it loads the column is empty (a loading line after 150 ms),
// never the previous lesson.
const lesson = lessonApi.data && lessonApi.data.lesson.id === lessonId && lessonApi.data.courseId === courseId ? lessonApi.data : null;
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]);
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 (!course) return <LoadingLine />;
if (lessonApi.error) {
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>
);
}
// The dock works from the course outline and the URL, so it is there (and right) while the lesson loads:
// a second Next goes on from the lesson the URL names.
const { current, prev, next } = lessonNeighbours(course, lessonId);
const isDone = progress?.state === "done";
const isExercise = (lesson?.lesson.type ?? current?.type) === "exercise";
return (
<CourseFrame course={course} currentLessonId={lessonId} look={look} lookMode={isExercise ? "work" : "toggle"}>
{lesson ? (
<LessonView
key={lesson.lesson.id}
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 "}${moduleNumber(course, lesson.module.index)}`.trim()}
/>
) : <div className="col" aria-busy="true"><LoadingLine /></div>}
<FloatingActionBar>
<Pill variant="ghost" disabled={!prev} title={prev ? `Previous: ${prev.title}` : "First lesson"} onClick={() => prev && navigate(lessonHref(courseId, prev.id))}>
<Prev /><span className="sr">Previous lesson</span>
</Pill>
<Pill variant="ghost" aria-pressed={isDone} disabled={!signedIn || !lesson || 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={!next} title={next ? `Next: ${next.title}` : "Last lesson"} onClick={() => next && navigate(lessonHref(courseId, 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 <LoadingLine />;
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>;
}