import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react"; import { Link, useLocation } from "react-router-dom"; import { CourseCover, ProgressBar } from "@stc/ui"; import type { CoursePayload, OutlineLesson, OutlineModule } from "../../shared/types"; import { courseHref, ensureStyles, fmtMinutes, fmtTime, lessonHref, useSearch, type Look } from "../api"; import { Check, Chev, Lock } from "../icons"; import { HeaderExtras } from "./HeaderExtras"; import { TitleText } from "../lesson/TitleText"; const fold = (s: string) => s.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase(); export function lessonKind(l: OutlineLesson): string { if (l.type === "video") return l.minutes ? (l.minutes < 60 ? fmtTime(l.minutes * 60) : fmtMinutes(l.minutes)) : "Video"; if (l.type === "exercise") return "Exercise"; return l.hasDeck ? "Slides" : "Text"; } export function courseDone(c: CoursePayload): { done: number; total: number } { const all = c.modules.flatMap((m) => m.lessons); return { done: all.filter((l) => c.progress[l.id]?.state === "done").length, total: all.length }; } /** Display number of a module: courses whose first module id ends in 0 ("m00") count from 0. */ export function moduleNumber(course: { modules: { id: string }[] }, index: number): number { return /(^|\D)0+$/.test(course.modules[0]?.id ?? "") ? index : index + 1; } export function accentStyle(accent: string | null | undefined): CSSProperties | undefined { return accent ? ({ "--accent": accent } as CSSProperties) : undefined; } /** The course page frame: outline sidebar (an overlay on narrow screens) and the main column. */ export function CourseFrame({ course, currentLessonId, look, lookMode, children }: { course: CoursePayload; currentLessonId: string | null; look: Look; lookMode: "toggle" | "work" | null; children: ReactNode; }) { ensureStyles(); const [open, setOpen] = useState(false); const loc = useLocation(); useEffect(() => { setOpen(false); }, [loc.pathname]); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [open]); return (
setOpen(!open) }} />
setOpen(false)} />
{children}
); } export function Outline({ course, currentLessonId }: { course: CoursePayload; currentLessonId: string | null }) { const [q] = useSearch(); const query = fold(q.trim()); const current = course.modules.find((m) => m.lessons.some((l) => l.id === currentLessonId)); const [openMods, setOpenMods] = useState>(() => new Set(current ? [current.id] : course.modules[0] ? [course.modules[0].id] : [])); useEffect(() => { if (current) setOpenMods((s) => (s.has(current.id) ? s : new Set([...s, current.id]))); }, [current?.id]); const loc = useLocation(); useEffect(() => { document.querySelector(".lr-les[aria-current='page']")?.scrollIntoView({ block: "nearest" }); }, [currentLessonId]); const { done, total } = courseDone(course); const pct = total ? done / total : 0; const notesPath = `${courseHref(course.id)}/notes`; const filtered = useMemo(() => course.modules .map((m) => ({ m, lessons: query ? m.lessons.filter((l) => fold(l.title).includes(query)) : m.lessons })) .filter(({ m, lessons }) => !query || lessons.length || fold(m.title).includes(query)), [course, query]); const toggle = (id: string) => setOpenMods((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; }); const sourceLine = course.authors.length ? course.authors.join(", ") : course.tracks.length > 1 ? `Tracks: ${course.tracks.map((t) => t.label).join(", ")}` : course.pitch ?? ""; const errors = course.diagnostics.filter((d) => d.level === "error"); return ( <>

{course.title}

{sourceLine ?
{sourceLine}
: null} {course.signedIn ? (
{Math.round(pct * 100)}%
) : null}
Overview {course.signedIn ? Your notes : null}
{errors.length ? (
{errors.length === 1 ? "1 problem" : `${errors.length} problems`} in this course (only authors see this)
    {errors.slice(0, 6).map((d, i) =>
  • {d.lessonId ? `${d.lessonId}: ` : ""}{d.message}{d.path ? ` (${d.path})` : ""}
  • )}
) : null} {filtered.map(({ m, lessons }) => ( toggle(m.id)} /> ))} {query && filtered.length === 0 ?

No lessons match “{q.trim()}”.

: null} ); } function ModuleBlock({ course, m, lessons, open, current, currentLessonId, onToggle }: { course: CoursePayload; m: OutlineModule; lessons: OutlineLesson[]; open: boolean; current: boolean; currentLessonId: string | null; onToggle: () => void; }) { const d = m.lessons.filter((l) => course.progress[l.id]?.state === "done").length; const pct = m.lessons.length ? d / m.lessons.length : 0; const number = String(moduleNumber(course, m.index)); return (
{course.signedIn ? : } {open ? (
    {lessons.map((l) => { const isDone = course.progress[l.id]?.state === "done"; const cls = ["lr-les", isDone && "done", m.locked && "locked"].filter(Boolean).join(" "); return (
  1. {isDone ? : m.locked ? : null} {isDone ? ", done" : l.id === currentLessonId ? ", current" : ""} {lessonKind(l)}
  2. ); })}
) : null}
); }