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 (

{title}

{children}
Back to the library
); } function CourseMissing({ status, signedIn }: { status: number; signedIn: boolean }) { if (status === 404) { return ( This course does not exist, or you do not have access to it.{signedIn ? "" : <> Signing in may help.} ); } return The course could not be loaded. Try again in a moment.; } /** 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 ; if (!course) return ; 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 (

{course.title}

{course.authors.length ?

{course.authors.join(", ")}

: null}
{course.pitch ?

{course.pitch}

: null}
Lessons
{total}
{minutes ?
Time
About {fmtMinutes(minutes)}
: null} {course.tracks.length > 1 ?
Tracks
{course.tracks.map((t) => t.label).join(", ")}
: null} {course.gating === "sequential" ?
Order
Each module opens when the one before it is done
: null} {course.signedIn ?
Progress
{done} of {total}
: null}
{course.status === "error" ?
This course has problems. {course.canEdit ? "The list in the sidebar names them." : "Some lessons may not open."}
: null}
{next ? {started ? "Continue" : "Start"}{started ? : {next.title.replace(/`/g, "")} : null} : null} {!course.signedIn ? Sign in to keep your progress and notes. : null}

Modules

{course.modules.map((m) => { const d = m.lessons.filter((l) => course.progress[l.id]?.state === "done").length; return ( {moduleNumber(course, m.index)} {m.title}{m.lessons.length === 1 ? "1 lesson" : `${m.lessons.length} lessons`}{m.locked ? " · locked" : ""} {course.signedIn ? : } ); })}
); } /* ---------- /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(`/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 ; if (!course) return ; if (lessonApi.error) { return (

Lesson not found

This course has no lesson “{lessonId}”.

); } // 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 ( {lesson ? ( { 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()} /> ) :
} prev && navigate(lessonHref(courseId, prev.id))}> Previous lesson void setState(isDone ? "not-started" : "done")}> {isDone ? "Done" : "Mark done"}
); } /* ---------- /c/:courseId/notes ---------- */ export function NotesPage() { const { courseId = "" } = useParams(); const { course, error } = useCourse(courseId); const { user, loaded } = useMe(); const [look] = useLook(); const notesApi = useApi(user ? `/api/courses/${encodeURIComponent(courseId)}/notes` : null); useEffect(() => { if (course) document.title = `Your notes – ${course.title}`; }, [course]); if (error && !course) return ; if (!course) return ; 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(); 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(`/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 (

{course.title}

Your notes

Notes on lessons, video moments and slides, in course order. Only you see them.

{!loaded ? null : !user ? (
Sign in to keep notes.
) : notesApi.loading && !notesApi.data ?

Loading…

: groups.size === 0 ?
No notes yet. Add them under a video, on a slide or at the end of a lesson.
: [...groups].map(([lid, ns]) => (

{titles.get(lid) ?

{titles.get(lid)!.module}

: null}
    {ns.map((n) => } />)}
))}
); } 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 Video {fmtTime(Number(note.anchorValue))}; if (note.anchorKind === "slide" && note.anchorValue !== null && note.deckId) return Slide {note.anchorValue}; return Lesson; }