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>
This commit is contained in:
1 parent
3988addaed
commit
a953e35178
2 files changed
+65
-11
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, type CSSProperties } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, type CSSProperties } from "react";
|
||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { CourseCover, FloatingActionBar, Pill, ProgressBar } from "@stc/ui";
|
||||
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";
|
||||
@@ -106,26 +106,34 @@ export function LessonPage() {
|
||||
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 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 progress = course?.progress[lessonId] ?? 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]);
|
||||
useEffect(() => { window.scrollTo(0, 0); }, [lessonId]);
|
||||
// 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(lessonId)}`, { method: "PUT", body: { state, positionSeconds } });
|
||||
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, lessonId, signedIn, progress?.state, reload]);
|
||||
}, [courseId, shownId, signedIn, progress?.state, reload]);
|
||||
|
||||
if (courseError && !course) return <CourseMissing status={courseError.status} signedIn={signedIn} />;
|
||||
if (lessonApi.error && course) {
|
||||
@@ -135,7 +143,7 @@ export function LessonPage() {
|
||||
</CourseFrame>
|
||||
);
|
||||
}
|
||||
if (!course || !lesson || lesson.lesson.id !== lessonId) {
|
||||
if (!course || !lesson) {
|
||||
return course ? <CourseFrame course={course} currentLessonId={lessonId} look={look} lookMode={null}><div className="col" /></CourseFrame> : null;
|
||||
}
|
||||
const isDone = progress?.state === "done";
|
||||
@@ -144,6 +152,7 @@ export function LessonPage() {
|
||||
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}
|
||||
@@ -153,7 +162,7 @@ export function LessonPage() {
|
||||
<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"}
|
||||
<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>
|
||||
|
||||
Reference in new issue
Block a user