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,138 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { CourseCover, SegmentedToggle } from "@stc/ui";
|
||||
import { courseHref, toast, useLibrary, useLook, useMe, useSearch, useToast } from "../api";
|
||||
import { DirI, Down, MenuI, SearchI } from "../icons";
|
||||
|
||||
export interface HeaderCourse { id: string; title: string; coverUrl: string | null; accent: string | null }
|
||||
|
||||
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||
|
||||
/**
|
||||
* Course switcher, search, Reading/Work and the account link, portalled into the Phase 0 header (`.stc-top`).
|
||||
* Rendered only after mount, so server-side rendering of a page skips it.
|
||||
*/
|
||||
export function HeaderExtras({ course, lookMode, outline }: {
|
||||
course: HeaderCourse | null;
|
||||
/** "toggle": show Reading/Work; "work": shown but fixed to Work (exercise pages); null: hidden. */
|
||||
lookMode: "toggle" | "work" | null;
|
||||
outline?: { open: boolean; toggle: () => void } | null;
|
||||
}) {
|
||||
const [host, setHost] = useState<Element | null>(null);
|
||||
useIsoLayoutEffect(() => { setHost(document.querySelector(".stc-top")); }, []);
|
||||
const msg = useToast();
|
||||
if (!host) return null;
|
||||
return (
|
||||
<>
|
||||
{createPortal(
|
||||
<div className="lr lr-h">
|
||||
{outline ? (
|
||||
<button type="button" className="lr-icon" aria-label="Course outline" aria-expanded={outline.open} aria-controls="lr-side" onClick={outline.toggle}><MenuI /></button>
|
||||
) : null}
|
||||
<CourseSwitcher course={course} />
|
||||
<SearchBox placeholder={course ? "Search this course" : "Search the library"} />
|
||||
{lookMode ? <LookToggle mode={lookMode} /> : null}
|
||||
<AccountLink />
|
||||
</div>,
|
||||
host,
|
||||
)}
|
||||
{msg ? <div className="lr lr-toast" role="status">{msg}</div> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LookToggle({ mode }: { mode: "toggle" | "work" }) {
|
||||
const [look, setLook] = useLook();
|
||||
const value = mode === "work" ? "work" : look;
|
||||
return (
|
||||
<SegmentedToggle
|
||||
label="Look"
|
||||
value={value}
|
||||
options={[{ value: "reading", label: "Reading" }, { value: "work", label: "Work" }]}
|
||||
onChange={(v) => {
|
||||
setLook(v as "reading" | "work");
|
||||
if (mode === "work" && v === "reading") toast("Exercises always use Work. Reading applies to text lessons.");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchBox({ placeholder }: { placeholder: string }) {
|
||||
const [q, setQ] = useSearch();
|
||||
const loc = useLocation();
|
||||
const path = useRef(loc.pathname.split("/").slice(0, 3).join("/"));
|
||||
// Clear the query when moving to another course or to the library.
|
||||
useEffect(() => {
|
||||
const p = loc.pathname.split("/").slice(0, 3).join("/");
|
||||
if (p !== path.current) { path.current = p; setQ(""); }
|
||||
}, [loc.pathname, setQ]);
|
||||
return (
|
||||
<label className="lr-search">
|
||||
<SearchI />
|
||||
<span className="sr">Search</span>
|
||||
<input
|
||||
type="search" value={q} placeholder={placeholder} autoComplete="off"
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") { setQ(""); (e.target as HTMLInputElement).blur(); } }}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function CourseSwitcher({ course }: { course: HeaderCourse | null }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const lib = useLibrary();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const loc = useLocation();
|
||||
useEffect(() => { setOpen(false); }, [loc.pathname]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false); };
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") { setOpen(false); ref.current?.querySelector<HTMLButtonElement>("button")?.focus(); }
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
document.addEventListener("keydown", onKey);
|
||||
ref.current?.querySelector<HTMLAnchorElement>(".lr-menu a")?.focus();
|
||||
return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
|
||||
}, [open]);
|
||||
const n = lib?.courses.length ?? 0;
|
||||
return (
|
||||
<div className="lr-switch" ref={ref}>
|
||||
<button type="button" className="lr-switch-btn" aria-haspopup="true" aria-expanded={open} onClick={() => setOpen(!open)}>
|
||||
{course
|
||||
? <span className="lr-cover-tiny"><CourseCover title={course.title} image={course.coverUrl ?? undefined} accent={course.accent ?? undefined} /></span>
|
||||
: <span className="lr-tile"><DirI /></span>}
|
||||
<span className="t">{course ? course.title : "All courses"}</span>
|
||||
<Down />
|
||||
</button>
|
||||
{open ? (
|
||||
<ul className="lr-menu">
|
||||
<li>
|
||||
<Link to="/" aria-current={course ? undefined : "page"}>
|
||||
<span className="lr-tile"><DirI /></span>
|
||||
<span><span className="mt">Library</span><span className="ms">{n === 1 ? "1 course" : `${n} courses`}</span></span>
|
||||
</Link>
|
||||
</li>
|
||||
{(lib?.courses ?? []).map((c) => (
|
||||
<li key={c.id}>
|
||||
<Link to={courseHref(c.id)} aria-current={course?.id === c.id ? "page" : undefined}>
|
||||
<span className="lr-cover-tiny"><CourseCover title={c.title} image={c.coverUrl ?? undefined} accent={c.accent ?? undefined} /></span>
|
||||
<span style={{ minWidth: 0 }}><span className="mt">{c.title}</span><span className="ms">{c.authors.join(", ") || (c.source === "authored" ? "Authored course" : c.source ? `Imported (${c.source})` : "")}</span></span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountLink() {
|
||||
const { loaded, user } = useMe();
|
||||
if (!loaded) return null;
|
||||
return user
|
||||
? <Link className="stc-pill stc-pill--ghost lr-acct" to="/account">{user.name || user.email}</Link>
|
||||
: <Link className="stc-pill lr-acct" to="/account">Sign in</Link>;
|
||||
}
|
||||
Reference in New Issue
Block a user