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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
99c0e4929f
commit
89c51f013d
@@ -0,0 +1,152 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user