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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user