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}
);
}
/* ---------- /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"}
next && navigate(lessonHref(courseId, next.id))}>
Next lesson
);
}
/* ---------- /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.