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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
305c262ff1
commit
b5e00af4f7
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, type CSSProperties } from "react";
|
||||
import { useCallback, useEffect, 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 { 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";
|
||||
@@ -52,7 +52,7 @@ export function CoursePage() {
|
||||
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;
|
||||
if (!course) return <LoadingLine />;
|
||||
const { done, total } = courseDone(course);
|
||||
const next = nextLesson(course);
|
||||
const started = Object.keys(course.progress).length > 0;
|
||||
@@ -100,74 +100,76 @@ export function CoursePage() {
|
||||
|
||||
/* ---------- /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)}`);
|
||||
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;
|
||||
// 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 shownId = lesson?.lesson.id ?? lessonId;
|
||||
const progress = course?.progress[shownId] ?? null;
|
||||
const progress = course?.progress[lessonId] ?? 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 } });
|
||||
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, shownId, signedIn, progress?.state, reload]);
|
||||
}, [courseId, lessonId, signedIn, progress?.state, reload]);
|
||||
|
||||
if (courseError && !course) return <CourseMissing status={courseError.status} signedIn={signedIn} />;
|
||||
if (lessonApi.error && course) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
if (!course || !lesson) {
|
||||
return course ? <CourseFrame course={course} currentLessonId={lessonId} look={look} lookMode={null}><div className="col" /></CourseFrame> : null;
|
||||
}
|
||||
// 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 === "exercise";
|
||||
const n = moduleNumber(course, lesson.module.index);
|
||||
const isExercise = (lesson?.lesson.type ?? current?.type) === "exercise";
|
||||
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()}
|
||||
/>
|
||||
{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={!lesson.prev} title={lesson.prev ? `Previous: ${lesson.prev.title}` : "First lesson"} onClick={() => lesson.prev && navigate(lessonHref(courseId, lesson.prev.id))}>
|
||||
<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.locked || held.stale} title={signedIn ? undefined : "Sign in to keep your progress"}
|
||||
<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={!lesson.next} title={lesson.next ? `Next: ${lesson.next.title}` : "Last lesson"} onClick={() => lesson.next && navigate(lessonHref(courseId, lesson.next.id))}>
|
||||
<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>
|
||||
@@ -185,7 +187,7 @@ export function NotesPage() {
|
||||
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;
|
||||
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 ?? [];
|
||||
|
||||
@@ -78,6 +78,7 @@ export function DeckViewer({ deck, notes, initialPage = 1, keyboard = true, titl
|
||||
setDoc(null); setError(null); setPages(0); setView({ front: 0, page: 0, dir: null });
|
||||
loadPdfjs()
|
||||
.then((pdfjs) => {
|
||||
if (!live) throw new Error("unmounted"); // never start a load (and its worker) after cleanup
|
||||
const t = pdfjs.getDocument({ url: deck.url, withCredentials: true });
|
||||
task = t;
|
||||
return t.promise;
|
||||
@@ -146,7 +147,7 @@ export function DeckViewer({ deck, notes, initialPage = 1, keyboard = true, titl
|
||||
<canvas
|
||||
key={i} ref={i ? canvasB : canvasA} hidden={!doc || !!error}
|
||||
data-front={front ? "" : undefined} data-page={front && view.page ? view.page : undefined} data-leaving={!front && view.dir ? "" : undefined} data-dir={view.dir ?? undefined}
|
||||
role={front ? "img" : undefined} aria-hidden={front ? undefined : true} aria-label={front ? `Slide ${page} of ${pages}` : undefined}
|
||||
role={front ? "img" : undefined} aria-hidden={front ? undefined : true} aria-label={front ? `Slide ${view.page || page} of ${pages}` : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { CourseCover, ProgressBar, SegmentedToggle } from "@stc/ui";
|
||||
import { CourseCover, ProgressBar, SegmentedToggle, LoadingLine } 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";
|
||||
@@ -31,7 +31,7 @@ export default function LibraryPage() {
|
||||
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>}
|
||||
{lib ? <LibraryView lib={lib} /> : <div aria-busy="true"><LoadingLine /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,9 +39,9 @@ export const learnerCss = String.raw`
|
||||
/* ---------- course shell ---------- */
|
||||
.stc-main:has(> .lr-shell) { padding: 0; }
|
||||
.lr-shell { display: flex; align-items: flex-start; }
|
||||
.lr-side { view-transition-name: 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-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-main { position: relative; 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; }
|
||||
@@ -332,9 +332,10 @@ export const learnerCss = String.raw`
|
||||
.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); }
|
||||
|
||||
/* ---------- motion ----------
|
||||
New content rises in when it arrives (a page, a lesson, the library); the header, sidebar and dock stay put.
|
||||
With view transitions the old page dissolves over it (tokens.css); without them this is the whole effect. */
|
||||
.lr-main .lr-lesson, .lr-main > article, .stc-main > .lr > article, .lr-lib { animation: stc-enter var(--t-enter) var(--ease-out) backwards; }
|
||||
Navigation swaps at once; only the new content region enters (fade and 6 px rise). The header, sidebar and dock
|
||||
stay put and nothing cross-fades. While a lesson loads its column keeps the dock at the bottom of the screen. */
|
||||
.lr-main .lr-lesson, .lr-main > article, .stc-main > .lr > article, .lr-lib { animation: stc-page var(--t-page) var(--ease-out) backwards; }
|
||||
.lr-main > .col[aria-busy="true"] { min-height: calc(100vh - var(--top) - 150px); }
|
||||
/* deck slides: the new slide fades in over the old one, 4 px from the side it comes from */
|
||||
.lr .stage canvas { grid-area: 1 / 1; }
|
||||
.lr .stage canvas:not([data-front]) { opacity: 0; }
|
||||
|
||||
Reference in New Issue
Block a user