Files
STCourses/packages/feature-learner/src/web/course/pages.tsx
T
Lucas WintherandClaude Opus 5.5 a953e35178 feat(learner): hold the lesson until the next one loads, swap with a view transition
The sidebar highlight moves at once; the lesson column keeps the current lesson until the next has loaded, then
swaps inside a view transition, and the route scroll (top, or the saved position on back) is applied then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 20:42:15 +01:00

242 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, useLayoutEffect, useMemo, type CSSProperties } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { CourseCover, FloatingActionBar, Pill, ProgressBar, routeScroll, useHeldValue } 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 loaded = lessonApi.data && lessonApi.data.lesson.id === lessonId && lessonApi.data.courseId === courseId ? lessonApi.data : null;
// Lesson to lesson: the current lesson stays on screen while the next loads, then the two swap in a view
// transition, so the column never blanks. Only within one course; the sidebar highlight moves at once.
const held = useHeldValue(loaded, (l) => l.lesson.id, (l) => l.courseId === courseId && !lessonApi.error);
const lesson = held.value;
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 shownId = lesson?.lesson.id ?? lessonId;
const progress = course?.progress[shownId] ?? null;
const signedIn = !!user;
useEffect(() => { if (lesson) document.title = `${lesson.lesson.title.replace(/`/g, "")} – ${lesson.courseTitle}`; }, [lesson]);
// While the old lesson is held, the shell's scroll for the new entry waits (this runs before the shell's
// layout effect) and is applied once the new lesson is on screen, inside the same transition.
useLayoutEffect(() => { if (held.stale) routeScroll().defer(); }, [loc.key]);
useLayoutEffect(() => { if (!held.stale || lessonApi.error) routeScroll().settle(); }, [lesson, held.stale, lessonApi.error]);
const setState = useCallback(async (state: ProgressState, positionSeconds?: number) => {
if (!signedIn) return;
try {
await api(`/api/courses/${encodeURIComponent(courseId)}/progress/${encodeURIComponent(shownId)}`, { 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, shownId, 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) {
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
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 "}${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 || held.stale} 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>;
}