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]>
244 lines
14 KiB
TypeScript
244 lines
14 KiB
TypeScript
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>;
|
||
}
|
||
|