Files
STCourses/packages/feature-learner/src/web/shell/CourseFrame.tsx
T
Lucas WintherandClaude Opus 5.5 89c51f013d feat(learner): web shell, library, lesson views (LessonView), decks, code files, exercises, notes, account and admin pages
Tasks 10-12 of the WP-A plan in one commit (the route modules import each other):
- header extras portalled into the Phase 0 header: course switcher, search, Reading/Work, account
- course frame with outline sidebar (mobile overlay, Escape closes), progress, gating locks
- library: featured course, continue row, New and collection rows, course list, filters, search
- LessonView: text (track switcher, folds, ordered hint tiers), video (resume, done near the end,
  timestamp notes, transcript), companion decks rendered with pdf.js (thumbs, arrows, fullscreen,
  per-slide notes), code files (tree, tabs, copy, zip, command), exercises (Web Worker runner or
  local command with optional pasted evidence), floating action bar
- notes list, account (email/password, providers, SSO by email) and owner admin pages
- pdf.js worker served from /api/learner/pdf.worker.mjs
- browser entry @stc/feature-learner/web (the package index also carries the server half)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:53:36 +01:00

153 lines
8.0 KiB
TypeScript

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 (
<div className={open ? "lr lr-shell side-open" : "lr lr-shell"} data-look={look} style={accentStyle(course.accent)}>
<HeaderExtras
course={{ id: course.id, title: course.title, coverUrl: course.coverUrl, accent: course.accent }}
lookMode={lookMode}
outline={{ open, toggle: () => setOpen(!open) }}
/>
<aside className="lr-side" id="lr-side" aria-label="Course outline">
<Outline course={course} currentLessonId={currentLessonId} />
</aside>
<div className="lr-scrim" onClick={() => setOpen(false)} />
<main className="lr-main">{children}</main>
</div>
);
}
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<Set<string>>(() => 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 (
<>
<div className="lr-side-head">
<span style={{ "--cw": "36px" } as CSSProperties}><CourseCover title={course.title} image={course.coverUrl ?? undefined} accent={course.accent ?? undefined} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<h2><Link to={courseHref(course.id)}>{course.title}</Link></h2>
{sourceLine ? <div className="src">{sourceLine}</div> : null}
{course.signedIn ? (
<div className="lr-prog"><ProgressBar value={pct} label="Course progress" /><span className="tnum">{Math.round(pct * 100)}%</span></div>
) : null}
<div className="lr-side-links">
<Link to={courseHref(course.id)} aria-current={loc.pathname === courseHref(course.id) ? "page" : undefined}>Overview</Link>
{course.signedIn ? <Link to={notesPath} aria-current={loc.pathname === notesPath ? "page" : undefined}>Your notes</Link> : null}
</div>
</div>
</div>
{errors.length ? (
<div className="lr-diag" role="note">
<b>{errors.length === 1 ? "1 problem" : `${errors.length} problems`} in this course</b> (only authors see this)
<ul>{errors.slice(0, 6).map((d, i) => <li key={i}>{d.lessonId ? `${d.lessonId}: ` : ""}{d.message}{d.path ? ` (${d.path})` : ""}</li>)}</ul>
</div>
) : null}
{filtered.map(({ m, lessons }) => (
<ModuleBlock key={m.id} course={course} m={m} lessons={lessons} open={!!query || openMods.has(m.id)} current={m.id === current?.id} currentLessonId={currentLessonId} onToggle={() => toggle(m.id)} />
))}
{query && filtered.length === 0 ? <p className="lr-nohit">No lessons match “{q.trim()}”.</p> : 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 (
<div className={current ? "lr-mod is-current" : "lr-mod"}>
<button type="button" className="lr-mod-head" aria-expanded={open} onClick={onToggle}>
<span className="n tnum">{m.locked ? <Lock /> : number}</span>
<span>{m.title}{m.locked ? <span className="sr"> (locked)</span> : null}</span>
<span className="c tnum">{course.signedIn ? `${d}/${m.lessons.length}` : m.lessons.length}</span>
<Chev />
</button>
{course.signedIn ? <ProgressBar value={pct} dim={pct === 1 || !current} label={`${m.title} progress`} /> : <span style={{ display: "block", height: 4 }} />}
{open ? (
<ol className="lr-lessons">
{lessons.map((l) => {
const isDone = course.progress[l.id]?.state === "done";
const cls = ["lr-les", isDone && "done", m.locked && "locked"].filter(Boolean).join(" ");
return (
<li key={l.id}>
<Link className={cls} to={lessonHref(course.id, l.id)} aria-current={l.id === currentLessonId ? "page" : undefined}>
<span className="m">{isDone ? <Check /> : m.locked ? <Lock /> : null}</span>
<span><TitleText text={l.title} /><span className="sr">{isDone ? ", done" : l.id === currentLessonId ? ", current" : ""}</span></span>
<span className="d tnum">{lessonKind(l)}</span>
</Link>
</li>
);
})}
</ol>
) : null}
</div>
);
}