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 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 04:53:36 +01:00
1 parent 99c0e4929f
commit 89c51f013d
27 files changed
+2994 -1

No files matched your search

@@ -0,0 +1,196 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Pill } from "@stc/ui";
import type { DeckRef } from "../../shared/types";
import { Full, Next, Prev } from "../icons";
import type { NotesApi } from "./notes";
/** The pdf.js worker is served by the learner API from the installed pdfjs-dist. */
export const PDF_WORKER_URL = "/api/learner/pdf.worker.mjs";
// biome-ignore lint/suspicious/noExplicitAny: pdf.js document proxy (types are loaded lazily with the library)
type PdfDoc = any;
let pdfjsPromise: Promise<typeof import("pdfjs-dist")> | null = null;
function loadPdfjs() {
pdfjsPromise ??= import("pdfjs-dist").then((m) => { m.GlobalWorkerOptions.workerSrc = PDF_WORKER_URL; return m; });
return pdfjsPromise;
}
/** Renders `page` of `doc` into `canvas` at `cssWidth` pixels wide (scaled for the device pixel ratio). */
async function renderPage(doc: PdfDoc, page: number, canvas: HTMLCanvasElement, cssWidth: number, cssMaxHeight?: number) {
const p = await doc.getPage(page);
const base = p.getViewport({ scale: 1 });
let scale = cssWidth / base.width;
if (cssMaxHeight && base.height * scale > cssMaxHeight) scale = cssMaxHeight / base.height;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const vp = p.getViewport({ scale: scale * dpr });
canvas.width = Math.floor(vp.width);
canvas.height = Math.floor(vp.height);
canvas.style.width = `${Math.floor(vp.width / dpr)}px`;
canvas.style.height = `${Math.floor(vp.height / dpr)}px`;
const task = p.render({ canvasContext: canvas.getContext("2d")!, viewport: vp, canvas });
await task.promise;
}
const isTyping = (t: EventTarget | null) => !!(t as Element | null)?.closest?.("input, textarea, select, video, audio, [contenteditable], [role='slider']");
/**
* Slide deck viewer: 16:9 stage, previous/next, page counter, thumbnails, fullscreen, arrow keys (when
* `keyboard` is set) and a note per slide. Pages are rendered in the browser with pdf.js.
*/
export function DeckViewer({ deck, notes, initialPage = 1, keyboard = true, title }: {
deck: DeckRef; notes: NotesApi | null; initialPage?: number; keyboard?: boolean; title?: string;
}) {
const [doc, setDoc] = useState<PdfDoc | null>(null);
const [pages, setPages] = useState(0);
const [page, setPage] = useState(initialPage);
const [error, setError] = useState<string | null>(null);
const stage = useRef<HTMLElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
useEffect(() => {
let live = true, loaded: PdfDoc | null = null;
setDoc(null); setError(null); setPages(0);
loadPdfjs()
.then((pdfjs) => pdfjs.getDocument({ url: deck.url, withCredentials: true }).promise)
.then((d: PdfDoc) => {
loaded = d;
if (!live) { void d.destroy(); return; }
setDoc(d); setPages(d.numPages); setPage((p) => Math.min(Math.max(1, p), d.numPages));
})
.catch((e: unknown) => live && setError(`This deck could not be opened. ${(e as Error).message ?? ""}`.trim()));
return () => { live = false; if (loaded) void loaded.destroy(); };
}, [deck.url]);
// Render the current page to fit the stage.
useEffect(() => {
if (!doc || !canvas.current || !stage.current) return;
let live = true;
const draw = () => {
const r = stage.current!.getBoundingClientRect();
if (live) renderPage(doc, page, canvas.current!, r.width, r.height).catch(() => {});
};
draw();
const ro = new ResizeObserver(() => draw());
ro.observe(stage.current);
return () => { live = false; ro.disconnect(); };
}, [doc, page]);
const go = useCallback((p: number) => setPage((cur) => (pages ? Math.min(Math.max(1, p), pages) : cur)), [pages]);
useEffect(() => {
if (!keyboard) return;
const onKey = (e: KeyboardEvent) => {
if (e.ctrlKey || e.metaKey || e.altKey || isTyping(e.target)) return;
if (e.key === "ArrowRight") { e.preventDefault(); setPage((p) => Math.min(p + 1, pages || p)); }
else if (e.key === "ArrowLeft") { e.preventDefault(); setPage((p) => Math.max(p - 1, 1)); }
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [keyboard, pages]);
useEffect(() => {
document.querySelector(`[data-deck="${CSS.escape(deck.id)}"] .thumbs [aria-current="true"]`)?.scrollIntoView({ block: "nearest", inline: "nearest" });
}, [page, deck.id]);
const fullscreen = () => {
if (document.fullscreenElement) void document.exitFullscreen();
else stage.current?.requestFullscreen?.().catch(() => {});
};
const slideNotes = notes?.notes.filter((n) => n.anchorKind === "slide" && n.deckId === deck.id) ?? [];
const withNote = new Set(slideNotes.map((n) => Number(n.anchorValue)));
return (
<section className="deck" data-deck={deck.id} aria-label={`Slides: ${title ?? deck.title}`}>
<figure className="stage" ref={stage}>
{error ? <span className="msg">{error} <a href={deck.url} target="_blank" rel="noopener">Open the PDF</a></span> : null}
{!error && !doc ? <span className="msg">Loading slides…</span> : null}
<canvas ref={canvas} hidden={!doc || !!error} role="img" aria-label={`Slide ${page} of ${pages}`} />
</figure>
<div className="deckbar">
<Pill className="sm" aria-label="Previous slide" disabled={page <= 1} onClick={() => go(page - 1)}><Prev /></Pill>
<span className="count tnum" aria-live="polite">{pages ? `${page} / ${pages}` : "– / –"}</span>
<Pill className="sm" aria-label="Next slide" disabled={!pages || page >= pages} onClick={() => go(page + 1)}><Next /></Pill>
<span className="gap" />
{keyboard ? <span className="hint">Arrow keys change slides</span> : null}
<a className="stc-pill stc-pill--ghost sm" href={deck.url} target="_blank" rel="noopener">PDF</a>
<Pill className="sm" onClick={fullscreen}><Full />Fullscreen</Pill>
</div>
{doc ? <Thumbs doc={doc} pages={pages} page={page} onPick={go} withNote={withNote} /> : null}
{notes ? <SlideNote notes={notes} deckId={deck.id} page={page} existing={slideNotes.filter((n) => Number(n.anchorValue) === page)} /> : null}
</section>
);
}
function Thumbs({ doc, pages, page, onPick, withNote }: { doc: PdfDoc; pages: number; page: number; onPick: (p: number) => void; withNote: Set<number> }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const root = ref.current;
if (!root) return;
const queue: HTMLCanvasElement[] = [];
let running = false, live = true;
const pump = async () => {
if (running) return;
running = true;
while (live && queue.length) {
const c = queue.shift()!;
await renderPage(doc, Number(c.dataset.p), c, 104).catch(() => {});
}
running = false;
};
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
const c = e.target as HTMLCanvasElement;
io.unobserve(c);
queue.push(c);
}
void pump();
}, { root, rootMargin: "0px 400px" });
root.querySelectorAll("canvas").forEach((c) => io.observe(c));
return () => { live = false; io.disconnect(); };
}, [doc, pages]);
return (
<div className="thumbs" ref={ref} role="group" aria-label="Slides">
{Array.from({ length: pages }, (_, i) => (
<button key={i} type="button" aria-label={`Slide ${i + 1}${withNote.has(i + 1) ? ", has a note" : ""}`} aria-current={page === i + 1} className={withNote.has(i + 1) ? "has-note" : undefined} onClick={() => onPick(i + 1)}>
<canvas data-p={i + 1} />
</button>
))}
</div>
);
}
function SlideNote({ notes, deckId, page, existing }: { notes: NotesApi; deckId: string; page: number; existing: { id: string; body: string }[] }) {
const first = existing[0];
const [text, setText] = useState(first?.body ?? "");
const [saved, setSaved] = useState<"idle" | "saving" | "saved">("idle");
const busy = useRef(false);
useEffect(() => { setText(first?.body ?? ""); setSaved("idle"); }, [page, deckId, first?.id, first?.body]);
if (!notes.enabled) return null;
const dirty = text.trim() !== (first?.body ?? "").trim();
const save = async () => {
if (!dirty || busy.current) return;
busy.current = true;
setSaved("saving");
const body = text.trim();
try {
if (first && !body) await notes.remove(first.id);
else if (first) await notes.update(first.id, body);
else if (body) await notes.create({ anchorKind: "slide", anchorValue: page, deckId, body });
setSaved("saved");
} finally { busy.current = false; }
};
return (
<div className="slide-note">
<label htmlFor={`sn-${deckId}`}>Your note on slide {page}</label>
<textarea id={`sn-${deckId}`} className="note-ta" value={text} onChange={(e) => { setText(e.target.value); setSaved("idle"); }} onBlur={save}
placeholder="Only you see this. It comes back when you return to this slide." />
<div className="note-actions">
<span className="msg">{saved === "saving" ? "Saving…" : saved === "saved" ? "Saved" : ""}</span>
{existing.length > 1 ? <span className="msg">{existing.length - 1} more on this slide in your notes list</span> : null}
<Pill className="sm" disabled={!dirty} onClick={save}>Save note</Pill>
</div>
</div>
);
}