Files
STCourses/packages/feature-learner/src/web/shell/HeaderExtras.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

139 lines
5.8 KiB
TypeScript

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>;
}