diff --git a/apps/web/src/features.ts b/apps/web/src/features.ts index 8b077c9..cdef4be 100644 --- a/apps/web/src/features.ts +++ b/apps/web/src/features.ts @@ -1,4 +1,5 @@ import type { WebFeature } from "@stc/contracts"; +import { learnerWeb } from "@stc/feature-learner/web"; // Phase 1 packages append one import line each and push into this list. -export const webFeatures: WebFeature[] = []; +export const webFeatures: WebFeature[] = [learnerWeb]; diff --git a/packages/feature-learner/package.json b/packages/feature-learner/package.json index 5da2328..807eab9 100644 --- a/packages/feature-learner/package.json +++ b/packages/feature-learner/package.json @@ -9,5 +9,9 @@ "better-auth": "^1.7.14", "marked": "^18.1.2", "pdfjs-dist": "^6.4.299" + }, + "exports": { + ".": "./src/index.ts", + "./web": "./web/src/index.ts" } } diff --git a/packages/feature-learner/src/index.ts b/packages/feature-learner/src/index.ts index 1988a66..c01208d 100644 --- a/packages/feature-learner/src/index.ts +++ b/packages/feature-learner/src/index.ts @@ -1,2 +1,7 @@ +// Server export { migrationsDirs, runMigrations } from "./server/migrate"; export { learnerFeature } from "./server/feature"; +// Browser (also available without the server half as "@stc/feature-learner/web") +export { learnerWeb, LessonView, type LessonViewProps, ensureLearnerStyles } from "./web"; +// Shared JSON shapes +export type * from "./shared/types"; diff --git a/packages/feature-learner/src/server/assets.ts b/packages/feature-learner/src/server/assets.ts new file mode 100644 index 0000000..8a41496 --- /dev/null +++ b/packages/feature-learner/src/server/assets.ts @@ -0,0 +1,20 @@ +import type { Feature } from "@stc/contracts"; + +/** The pdf.js worker from the installed pdfjs-dist, for client-side deck rendering. */ +export function pdfWorkerPath(): string { + return Bun.resolveSync("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.dir); +} + +export function assetRoutes(): Feature["routes"] { + let path: string | null = null; + return { + "/api/learner/pdf.worker.mjs": { + GET: async () => { + path ??= pdfWorkerPath(); + return new Response(Bun.file(path), { + headers: { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "public, max-age=86400" }, + }); + }, + }, + }; +} diff --git a/packages/feature-learner/src/server/feature.ts b/packages/feature-learner/src/server/feature.ts index 513bc79..0d2c512 100644 --- a/packages/feature-learner/src/server/feature.ts +++ b/packages/feature-learner/src/server/feature.ts @@ -8,6 +8,7 @@ import { lessonRoutes } from "./lesson"; import { userDataRoutes } from "./userdata"; import { codeRoutes } from "./code"; import { transpileRoutes } from "./transpile"; +import { assetRoutes } from "./assets"; /** * The learner feature: auth, library, reader, notes, progress and exercises. @@ -32,6 +33,7 @@ export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthServic ...userDataRoutes(ctx), ...codeRoutes(ctx), ...transpileRoutes(), + ...assetRoutes(), }; // Every handler waits for the learner migrations (a no-op once Phase 2 runs them in migrate()). for (const methods of Object.values(routes)) { diff --git a/packages/feature-learner/src/web/account/AccountPage.tsx b/packages/feature-learner/src/web/account/AccountPage.tsx new file mode 100644 index 0000000..be89024 --- /dev/null +++ b/packages/feature-learner/src/web/account/AccountPage.tsx @@ -0,0 +1,132 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { Link, useLocation, useNavigate } from "react-router-dom"; +import { Pill, SegmentedToggle } from "@stc/ui"; +import { api, clearCourseCache, ensureStyles, refreshLibrary, refreshMe, useApi, useMe } from "../api"; +import { HeaderExtras } from "../shell/HeaderExtras"; + +export interface ProvidersPayload { emailPassword: boolean; providers: { id: string; label: string; kind: "social" | "oidc" }[]; sso: boolean } + +async function afterAuthChange() { + clearCourseCache(); + await Promise.all([refreshMe(), refreshLibrary()]); +} + +/** Only same-site paths are allowed as a return target. */ +export function safeNext(raw: string | null): string { + return raw && raw.startsWith("/") && !raw.startsWith("//") ? raw : "/"; +} + +export default function AccountPage() { + ensureStyles(); + const { loaded, user } = useMe(); + useEffect(() => { document.title = "Account – STCourses"; }, []); + return ( +
+ +
+ {!loaded ? null : user ? : } +
+
+ ); +} + +function SignedIn() { + const { user } = useMe(); + const navigate = useNavigate(); + const [busy, setBusy] = useState(false); + if (!user) return null; + const signOut = async () => { + setBusy(true); + try { await api("/api/auth/sign-out", { method: "POST", body: {} }); } catch { /* the session may already be gone */ } + await afterAuthChange(); + setBusy(false); + navigate("/"); + }; + return ( + <> +

Your account

+
+
Name
{user.name || "—"}
+
Email
{user.email}
+
Roles
{user.roles.join(", ") || "learner"}
+ {user.groups.length ? <>
Groups
{user.groups.join(", ")}
: null} +
+
+ {user.roles.includes("owner") ? Users, groups and library : null} + Sign out +
+ + ); +} + +export function SignInForms() { + const loc = useLocation(); + const navigate = useNavigate(); + const next = safeNext(new URLSearchParams(loc.search).get("next")); + const { data: prov } = useApi("/api/learner/providers"); + const [mode, setMode] = useState<"in" | "up">("in"); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [ssoEmail, setSsoEmail] = useState(""); + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(false); + + const submit = async (e: FormEvent) => { + e.preventDefault(); + setErr(null); setBusy(true); + try { + if (mode === "in") await api("/api/auth/sign-in/email", { method: "POST", body: { email, password } }); + else await api("/api/auth/sign-up/email", { method: "POST", body: { name: name.trim() || email.split("@")[0], email, password } }); + await afterAuthChange(); + navigate(next); + } catch (e) { + setErr(mode === "in" ? "That email and password do not match an account." : (e as Error).message); + } finally { setBusy(false); } + }; + const redirect = async (path: string, body: Record) => { + setErr(null); + try { + const callbackURL = new URL(next, location.origin).toString(); + const r = await api<{ url?: string }>(path, { method: "POST", body: { ...body, callbackURL } }); + if (r?.url) location.assign(r.url); else setErr("The sign-in provider did not answer."); + } catch (e) { setErr((e as Error).message); } + }; + + return ( + <> +

{mode === "in" ? "Sign in" : "Create an account"}

+

Your progress, notes and exercise code are kept with your account.

+
+ { setMode(v as "in" | "up"); setErr(null); }} + options={[{ value: "in", label: "Sign in" }, { value: "up", label: "Create account" }]} /> +
+
+ {mode === "up" ? : null} + + + {err ?

{err}

: null} +
{mode === "in" ? "Sign in" : "Create account"}
+ {mode === "up" ?

The first account on a new server becomes its owner.

: null} +
+ {prov && (prov.providers.length || prov.sso) ? ( + <> +
or
+
+ {prov.providers.map((p) => ( + void (p.kind === "oidc" ? redirect("/api/auth/sign-in/oauth2", { providerId: p.id }) : redirect("/api/auth/sign-in/social", { provider: p.id }))}> + Continue with {p.label} + + ))} +
+ {prov.sso ? ( +
{ e.preventDefault(); void redirect("/api/auth/sign-in/sso", { email: ssoEmail }); }}> + +
Continue with single sign-on
+
+ ) : null} + + ) : null} + + ); +} diff --git a/packages/feature-learner/src/web/account/AdminPage.tsx b/packages/feature-learner/src/web/account/AdminPage.tsx new file mode 100644 index 0000000..632c12b --- /dev/null +++ b/packages/feature-learner/src/web/account/AdminPage.tsx @@ -0,0 +1,207 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { Pill } from "@stc/ui"; +import type { Role } from "@stc/contracts"; +import { api, ensureStyles, refreshLibrary, toast, useApi, useLibrary, useMe } from "../api"; +import { HeaderExtras } from "../shell/HeaderExtras"; + +interface AdminUser { id: string; email: string; name: string; createdAt: string; roles: Role[]; groups: string[] } +interface AdminGroup { id: string; name: string; members: string[] } +interface Collection { id: string; title: string; sort: number; courseIds: string[] } +interface Access { visibility: string; acl: { principal_type: "user" | "group"; principal_id: string }[]; authors: string[] } + +const ROLES: Role[] = ["owner", "author", "learner"]; + +/** Owner administration: roles, groups, course access and library curation. */ +export default function AdminPage() { + ensureStyles(); + const { loaded, user } = useMe(); + useEffect(() => { document.title = "Administration – STCourses"; }, []); + const owner = !!user?.roles.includes("owner"); + return ( +
+ +
+

Account

+

Users, groups and library

+ {!loaded ? null : !owner ?

Only the owner of this server can open this page.

: } +
+
+ ); +} + +function AdminSections() { + const users = useApi("/api/admin/users"); + const groups = useApi("/api/admin/groups"); + return ( + <> +

Sign-in providers are configured on the server with STC_AUTH_* settings.

+ + { groups.reload(); users.reload(); }} /> + + + + ); +} + +function UsersSection({ users, reload }: { users: AdminUser[]; reload: () => void }) { + const setRoles = async (u: AdminUser, role: Role, on: boolean) => { + const roles = on ? [...u.roles, role] : u.roles.filter((r) => r !== role); + try { await api(`/api/admin/users/${encodeURIComponent(u.id)}/roles`, { method: "PUT", body: { roles } }); reload(); } + catch (e) { toast((e as Error).message); } + }; + return ( +
+

Users and roles

+ + + + {users.map((u) => ( + + + + + + ))} + +
UserRolesGroups
{u.name || u.email}
{u.email}
{ROLES.map((r) => ( + + ))}
{u.groups.join(", ")}
+
+ ); +} + +function GroupsSection({ groups, users, reload }: { groups: AdminGroup[]; users: AdminUser[]; reload: () => void }) { + const [name, setName] = useState(""); + const create = async () => { + try { await api("/api/admin/groups", { method: "POST", body: { name } }); setName(""); reload(); } catch (e) { toast((e as Error).message); } + }; + const setMembers = async (g: AdminGroup, userIds: string[]) => { + try { await api(`/api/admin/groups/${encodeURIComponent(g.id)}/members`, { method: "PUT", body: { userIds } }); reload(); } catch (e) { toast((e as Error).message); } + }; + const remove = async (g: AdminGroup) => { + if (!confirm(`Delete the group “${g.name}”? Courses shared with it lose that access.`)) return; + try { await api(`/api/admin/groups/${encodeURIComponent(g.id)}`, { method: "DELETE" }); reload(); } catch (e) { toast((e as Error).message); } + }; + return ( +
+

Groups

+

Groups give access to restricted courses. Groups named in OIDC or SAML claims are filled at sign-in.

+ {groups.length ? ( + + + + {groups.map((g) => ( + + + + + + ))} + +
GroupMembers
{g.name}
{users.map((u) => ( + + ))}
remove(g)}>Delete
+ ) : null} +
+ setName(e.target.value)} aria-label="New group name" /> + Add group +
+
+ ); +} + +function LibrarySection() { + const lib = useLibrary(); + const cur = useApi<{ featured: string | null; collections: Collection[] }>("/api/admin/library"); + const [featured, setFeatured] = useState(""); + const [cols, setCols] = useState([]); + const [dirty, setDirty] = useState(false); + useEffect(() => { + if (!cur.data) return; + setFeatured(cur.data.featured ?? ""); setCols(cur.data.collections); setDirty(false); + }, [cur.data]); + const courses = lib?.courses ?? []; + const save = async () => { + try { + await api("/api/admin/library", { method: "PUT", body: { featured: featured || null, collections: cols.filter((c) => c.title.trim()).map((c, i) => ({ ...c, sort: i })) } }); + cur.reload(); void refreshLibrary(); toast("Library saved"); + } catch (e) { toast((e as Error).message); } + }; + const edit = (i: number, c: Partial) => { setCols(cols.map((x, k) => (k === i ? { ...x, ...c } : x))); setDirty(true); }; + return ( +
+

Library

+
+ + +
+

Collections

+

Each collection is a row in the library. A course can be in several. Courses can also name collections in their manifest.

+ + + {cols.map((c, i) => ( + + + + + + ))} + +
edit(i, { title: e.target.value })} />
{courses.map((co) => ( + + ))}
{ setCols(cols.filter((_, k) => k !== i)); setDirty(true); }}>Remove
+
+ { setCols([...cols, { id: "", title: "", sort: cols.length, courseIds: [] }]); setDirty(true); }}>Add collection + Save library +
+
+ ); +} + +function AccessSection({ users, groups }: { users: AdminUser[]; groups: AdminGroup[] }) { + const lib = useLibrary(); + const [courseId, setCourseId] = useState(""); + const access = useApi(courseId ? `/api/admin/courses/${encodeURIComponent(courseId)}/access` : null); + const [draft, setDraft] = useState(null); + useEffect(() => { setDraft(access.data); }, [access.data]); + const has = (t: "user" | "group", id: string) => !!draft?.acl.some((a) => a.principal_type === t && a.principal_id === id); + const toggle = (t: "user" | "group", id: string, on: boolean) => draft && setDraft({ + ...draft, acl: on ? [...draft.acl, { principal_type: t, principal_id: id }] : draft.acl.filter((a) => !(a.principal_type === t && a.principal_id === id)), + }); + const save = async () => { + if (!draft) return; + try { await api(`/api/admin/courses/${encodeURIComponent(courseId)}/access`, { method: "PUT", body: { acl: draft.acl, authors: draft.authors } }); access.reload(); toast("Access saved"); } + catch (e) { toast((e as Error).message); } + }; + return ( +
+

Course access

+

Restricted courses are visible to the users and groups listed here. Authors can edit a course in the studio.

+
+ + {draft ? Visibility in the manifest: {draft.visibility} : null} +
+ {draft ? ( + <> + + + + + + +
Users
{users.map((u) => )}
Groups
{groups.length ? groups.map((g) => ) : No groups yet}
Authors
{users.map((u) => )}
+
Save access
+ + ) : null} +
+ ); +} diff --git a/packages/feature-learner/src/web/api.ts b/packages/feature-learner/src/web/api.ts new file mode 100644 index 0000000..9826e1a --- /dev/null +++ b/packages/feature-learner/src/web/api.ts @@ -0,0 +1,197 @@ +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import type { AuthUser } from "@stc/contracts"; +import type { CoursePayload, LibraryPayload } from "../shared/types"; +import { learnerCss } from "./styles"; + +/** Injects the learner stylesheet once (the module graph stays importable on the server). */ +export function ensureStyles(): void { + if (typeof document === "undefined" || document.getElementById("stc-learner-css")) return; + const el = document.createElement("style"); + el.id = "stc-learner-css"; + el.textContent = learnerCss; + document.head.append(el); +} +export { learnerCss }; + +export class HttpError extends Error { + constructor(public status: number, message: string) { super(message); } +} + +export async function api(path: string, init: { method?: string; body?: unknown } = {}): Promise { + const r = await fetch(path, { + method: init.method ?? "GET", + credentials: "same-origin", + headers: init.body !== undefined ? { "content-type": "application/json" } : undefined, + body: init.body !== undefined ? JSON.stringify(init.body) : undefined, + }); + const text = await r.text(); + let data: unknown = null; + try { data = text ? JSON.parse(text) : null; } catch { data = text; } + if (!r.ok) { + const msg = (data && typeof data === "object" && ("error" in data || "message" in data)) + ? String((data as { error?: string; message?: string }).error ?? (data as { message?: string }).message) + : r.status === 404 ? "Not found" : `Request failed (${r.status})`; + throw new HttpError(r.status, msg); + } + return data as T; +} + +/** Loads `path` (null skips). `reload()` refetches without clearing the current data. */ +export function useApi(path: string | null): { data: T | null; error: HttpError | null; loading: boolean; reload: () => void; set: (v: T) => void } { + const [state, setState] = useState<{ path: string | null; data: T | null; error: HttpError | null; loading: boolean }>({ path, data: null, error: null, loading: !!path }); + const [n, setN] = useState(0); + const current = state.path === path ? state : { path, data: null, error: null, loading: !!path }; + useEffect(() => { + if (!path) return; + let live = true; + setState((s) => (s.path === path ? { ...s, loading: true } : { path, data: null, error: null, loading: true })); + api(path).then( + (data) => live && setState({ path, data, error: null, loading: false }), + (e: unknown) => live && setState({ path, data: null, error: e instanceof HttpError ? e : new HttpError(0, String(e)), loading: false }), + ); + return () => { live = false; }; + }, [path, n]); + const reload = useCallback(() => setN((x) => x + 1), []); + const set = useCallback((v: T) => setState((s) => ({ ...s, data: v })), []); + return { data: current.data, error: current.error, loading: current.loading, reload, set }; +} + +/* ---------- tiny shared stores ---------- */ + +function store(initial: T) { + let value = initial; + const subs = new Set<() => void>(); + return { + get: () => value, + set(v: T) { if (v !== value) { value = v; subs.forEach((f) => f()); } }, + subscribe(f: () => void) { subs.add(f); return () => { subs.delete(f); }; }, + }; +} +function useStore(s: ReturnType>): T { + return useSyncExternalStore(s.subscribe, s.get, s.get); +} + +const ls = { + get(k: string): string | null { try { return localStorage.getItem(k); } catch { return null; } }, + set(k: string, v: string) { try { localStorage.setItem(k, v); } catch { /* private mode */ } }, +}; + +export type Look = "reading" | "work"; +const lookStore = store((typeof window !== "undefined" && ls.get("stc.look") === "work") ? "work" : "reading"); +export function useLook(): [Look, (l: Look) => void] { + const v = useStore(lookStore); + return [v, (l) => { ls.set("stc.look", l); lookStore.set(l); }]; +} + +const searchStore = store(""); +export function useSearch(): [string, (q: string) => void] { + return [useStore(searchStore), searchStore.set]; +} + +const trackStore = store(0); +export function useTrack(courseId: string, tracks: { id: string }[]): [string | null, (t: string) => void] { + useStore(trackStore); + const saved = typeof window !== "undefined" ? ls.get(`stc.track.${courseId}`) : null; + const value = tracks.find((t) => t.id === saved)?.id ?? tracks[0]?.id ?? null; + return [value, (t) => { ls.set(`stc.track.${courseId}`, t); trackStore.set(trackStore.get() + 1); }]; +} + +/* ---------- session and caches ---------- */ + +const meStore = store<{ loaded: boolean; user: AuthUser | null }>({ loaded: false, user: null }); +let meLoading: Promise | null = null; +export function refreshMe(): Promise { + meLoading = api<{ user: AuthUser | null }>("/api/me").then( + (r) => meStore.set({ loaded: true, user: r.user }), + () => meStore.set({ loaded: true, user: null }), + ); + return meLoading; +} +export function useMe(): { loaded: boolean; user: AuthUser | null } { + const v = useStore(meStore); + useEffect(() => { if (!v.loaded && !meLoading) void refreshMe(); }, [v.loaded]); + return v; +} + +const libraryStore = store(null); +let libraryLoading: Promise | null = null; +export function refreshLibrary(): Promise { + libraryLoading = api("/api/library").then((l) => libraryStore.set(l), () => { /* keep stale */ }); + return libraryLoading; +} +export function useLibrary(): LibraryPayload | null { + const v = useStore(libraryStore); + useEffect(() => { if (!libraryLoading) void refreshLibrary(); }, []); + return v; +} + +const courseCache = new Map(); +const courseVersion = store(0); +/** The course outline with progress. Cached across route changes; `reload` refetches. */ +export function useCourse(courseId: string): { course: CoursePayload | null; error: HttpError | null; reload: () => Promise } { + useStore(courseVersion); + const [error, setError] = useState(null); + const live = useRef(true); + const reload = useCallback(async () => { + try { + const c = await api(`/api/courses/${encodeURIComponent(courseId)}`); + courseCache.set(courseId, c); + if (live.current) setError(null); + courseVersion.set(courseVersion.get() + 1); + } catch (e) { + if (live.current) setError(e instanceof HttpError ? e : new HttpError(0, String(e))); + } + }, [courseId]); + useEffect(() => { + live.current = true; + setError(null); + void reload(); + return () => { live.current = false; }; + }, [reload]); + return { course: courseCache.get(courseId) ?? null, error, reload }; +} +export function clearCourseCache(): void { courseCache.clear(); courseVersion.set(courseVersion.get() + 1); } + +/* ---------- toast ---------- */ + +const toastStore = store(null); +let toastTimer: ReturnType | undefined; +export function toast(msg: string): void { + toastStore.set(msg); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => toastStore.set(null), 2600); +} +export function useToast(): string | null { return useStore(toastStore); } + +/* ---------- formatting ---------- */ + +export function fmtTime(s: number): string { + const t = Math.max(0, Math.floor(s)); + const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), sec = t % 60; + return h ? `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}` : `${m}:${String(sec).padStart(2, "0")}`; +} +export function fmtMinutes(min: number | null | undefined): string { + if (!min) return ""; + if (min < 60) return `${Math.round(min)} min`; + const h = Math.floor(min / 60), m = Math.round(min % 60); + return m ? `${h} h ${m} min` : `${h} h`; +} +export function fmtSize(n: number | null | undefined): string { + if (n === null || n === undefined) return ""; + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`; + return `${(n / 1024 / 1024).toFixed(1)} MB`; +} +export const lessonHref = (courseId: string, lessonId: string) => `/c/${encodeURIComponent(courseId)}/${encodeURIComponent(lessonId)}`; +export const courseHref = (courseId: string) => `/c/${encodeURIComponent(courseId)}`; + +export async function copyText(text: string): Promise { + try { await navigator.clipboard.writeText(text); return true; } catch { + const t = document.createElement("textarea"); + t.value = text; document.body.append(t); t.select(); + let ok = false; + try { ok = document.execCommand("copy"); } catch { /* unsupported */ } + t.remove(); + return ok; + } +} diff --git a/packages/feature-learner/src/web/course/pages.tsx b/packages/feature-learner/src/web/course/pages.tsx new file mode 100644 index 0000000..39eb4c9 --- /dev/null +++ b/packages/feature-learner/src/web/course/pages.tsx @@ -0,0 +1,232 @@ +import { useCallback, useEffect, useMemo, type CSSProperties } from "react"; +import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; +import { CourseCover, FloatingActionBar, Pill, ProgressBar } from "@stc/ui"; +import type { CoursePayload, LessonPayload, Note, ProgressState } from "../../shared/types"; +import { api, ensureStyles, fmtMinutes, fmtTime, lessonHref, refreshLibrary, toast, useApi, useCourse, useLook, useMe, useTrack } from "../api"; +import { Check, Next, Prev } from "../icons"; +import { CourseFrame, courseDone, moduleNumber } from "../shell/CourseFrame"; +import { HeaderExtras } from "../shell/HeaderExtras"; +import { LessonView, TitleText } from "../lesson/LessonView"; +import { NoteItem } from "../lesson/notes"; + +function Problem({ title, children }: { title: string; children?: React.ReactNode }) { + ensureStyles(); + return ( +
+ +
+

{title}

+
{children}
+ Back to the library +
+
+ ); +} + +function CourseMissing({ status, signedIn }: { status: number; signedIn: boolean }) { + if (status === 404) { + return ( + + This course does not exist, or you do not have access to it.{signedIn ? "" : <> Signing in may help.} + + ); + } + return The course could not be loaded. Try again in a moment.; +} + +/** First lesson that is not done (in an unlocked module), or the first lesson. */ +export function nextLesson(c: CoursePayload): { id: string; title: string } | null { + for (const m of c.modules) { + if (m.locked) break; + for (const l of m.lessons) if (c.progress[l.id]?.state !== "done") return l; + } + return c.modules[0]?.lessons[0] ?? null; +} + +/* ---------- /c/:courseId ---------- */ + +export function CoursePage() { + const { courseId = "" } = useParams(); + const { course, error } = useCourse(courseId); + const { user } = useMe(); + const [look] = useLook(); + useEffect(() => { if (course) document.title = `${course.title} – STCourses`; }, [course]); + if (error && !course) return ; + if (!course) return null; + const { done, total } = courseDone(course); + const next = nextLesson(course); + const started = Object.keys(course.progress).length > 0; + const minutes = course.modules.flatMap((m) => m.lessons).reduce((s, l) => s + (l.minutes ?? 0), 0); + return ( + +
+
+ +
+

{course.title}

+ {course.authors.length ?

{course.authors.join(", ")}

: null} +
+
+ {course.pitch ?

{course.pitch}

: null} +
+
Lessons
{total}
+ {minutes ?
Time
About {fmtMinutes(minutes)}
: null} + {course.tracks.length > 1 ?
Tracks
{course.tracks.map((t) => t.label).join(", ")}
: null} + {course.gating === "sequential" ?
Order
Each module opens when the one before it is done
: null} + {course.signedIn ?
Progress
{done} of {total}
: null} +
+ {course.status === "error" ?
This course has problems. {course.canEdit ? "The list in the sidebar names them." : "Some lessons may not open."}
: null} +
+ {next ? {started ? "Continue" : "Start"}{started ? : {next.title.replace(/`/g, "")} : null} : null} + {!course.signedIn ? Sign in to keep your progress and notes. : null} +
+

Modules

+
+ {course.modules.map((m) => { + const d = m.lessons.filter((l) => course.progress[l.id]?.state === "done").length; + return ( + + {moduleNumber(course, m.index)} + {m.title}{m.lessons.length === 1 ? "1 lesson" : `${m.lessons.length} lessons`}{m.locked ? " · locked" : ""} + {course.signedIn ? : } + + ); + })} +
+
+
+ ); +} + +/* ---------- /c/:courseId/:lessonId ---------- */ + +export function LessonPage() { + const { courseId = "", lessonId = "" } = useParams(); + const loc = useLocation(); + const navigate = useNavigate(); + const { course, error: courseError, reload } = useCourse(courseId); + const lessonApi = useApi(`/api/courses/${encodeURIComponent(courseId)}/lessons/${encodeURIComponent(lessonId)}`); + const lesson = lessonApi.data; + const { user } = useMe(); + const [look] = useLook(); + const [track, setTrack] = useTrack(courseId, course?.tracks ?? lesson?.tracks ?? []); + const q = useMemo(() => new URLSearchParams(loc.search), [loc.search]); + const initialTime = q.has("t") ? Number(q.get("t")) : null; + const initialSlide = q.has("slide") && q.has("deck") ? { deckId: q.get("deck")!, page: Number(q.get("slide")) } : null; + const progress = course?.progress[lessonId] ?? null; + const signedIn = !!user; + + useEffect(() => { if (lesson) document.title = `${lesson.lesson.title.replace(/`/g, "")} – ${lesson.courseTitle}`; }, [lesson]); + useEffect(() => { window.scrollTo(0, 0); }, [lessonId]); + + const setState = useCallback(async (state: ProgressState, positionSeconds?: number) => { + if (!signedIn) return; + try { + await api(`/api/courses/${encodeURIComponent(courseId)}/progress/${encodeURIComponent(lessonId)}`, { method: "PUT", body: { state, positionSeconds } }); + if (state === "done" || progress?.state !== state) { await reload(); void refreshLibrary(); } + } catch (e) { toast(`Progress was not saved: ${(e as Error).message}`); } + }, [courseId, lessonId, signedIn, progress?.state, reload]); + + if (courseError && !course) return ; + if (lessonApi.error && course) { + return ( + +

Lesson not found

This course has no lesson “{lessonId}”.

+
+ ); + } + if (!course || !lesson || lesson.lesson.id !== lessonId) { + return course ?
: null; + } + const isDone = progress?.state === "done"; + const isExercise = lesson.lesson.type === "exercise"; + const n = moduleNumber(course, lesson.module.index); + return ( + + { void reload(); void refreshLibrary(); }} + initialTime={initialTime} initialSlide={initialSlide} + moduleLabel={`${/^(section|part|chapter)\b/i.test(lesson.module.title) ? "" : "Module "}${n}`.trim()} + /> + + lesson.prev && navigate(lessonHref(courseId, lesson.prev.id))}> + Previous lesson + + void setState(isDone ? "not-started" : "done")}> + {isDone ? "Done" : "Mark done"} + + + + ); +} + +/* ---------- /c/:courseId/notes ---------- */ + +export function NotesPage() { + const { courseId = "" } = useParams(); + const { course, error } = useCourse(courseId); + const { user, loaded } = useMe(); + const [look] = useLook(); + const notesApi = useApi(user ? `/api/courses/${encodeURIComponent(courseId)}/notes` : null); + useEffect(() => { if (course) document.title = `Your notes – ${course.title}`; }, [course]); + if (error && !course) return ; + if (!course) return null; + const titles = new Map(course.modules.flatMap((m) => m.lessons.map((l) => [l.id, { title: l.title, module: m.title }] as const))); + const order = new Map(course.modules.flatMap((m) => m.lessons).map((l, i) => [l.id, i])); + const notes = notesApi.data ?? []; + const groups = new Map(); + for (const n of [...notes].sort((a, b) => (order.get(a.lessonId) ?? 1e9) - (order.get(b.lessonId) ?? 1e9) || anchorNum(a) - anchorNum(b) || a.createdAt.localeCompare(b.createdAt))) { + if (!groups.has(n.lessonId)) groups.set(n.lessonId, []); + groups.get(n.lessonId)!.push(n); + } + const remove = async (id: string) => { + try { + await api(`/api/notes/${encodeURIComponent(id)}`, { method: "DELETE" }); + notesApi.set(notes.filter((n) => n.id !== id)); + } catch (e) { toast(`The note was not deleted: ${(e as Error).message}`); } + }; + const update = async (id: string, body: string) => { + try { + const n = await api(`/api/notes/${encodeURIComponent(id)}`, { method: "PATCH", body: { body } }); + notesApi.set(notes.map((x) => (x.id === id ? n : x))); + } catch (e) { toast(`The note was not saved: ${(e as Error).message}`); } + }; + return ( + +
+

{course.title}

+

Your notes

+

Notes on lessons, video moments and slides, in course order. Only you see them.

+ {!loaded ? null : !user ? ( +
Sign in to keep notes.
+ ) : notesApi.loading && !notesApi.data ?

Loading…

+ : groups.size === 0 ?
No notes yet. Add them under a video, on a slide or at the end of a lesson.
+ : [...groups].map(([lid, ns]) => ( +
+

+ {titles.get(lid) ?

{titles.get(lid)!.module}

: null} +
    + {ns.map((n) => } />)} +
+
+ ))} +
+
+ ); +} + +const anchorNum = (n: Note) => (n.anchorKind === "none" ? -1 : Number(n.anchorValue ?? 0)); + +function AnchorLink({ note }: { note: Note }) { + const base = lessonHref(note.courseId, note.lessonId); + if (note.anchorKind === "video" && note.anchorValue !== null) return Video {fmtTime(Number(note.anchorValue))}; + if (note.anchorKind === "slide" && note.anchorValue !== null && note.deckId) return Slide {note.anchorValue}; + return Lesson; +} + diff --git a/packages/feature-learner/src/web/icons.tsx b/packages/feature-learner/src/web/icons.tsx new file mode 100644 index 0000000..8553689 --- /dev/null +++ b/packages/feature-learner/src/web/icons.tsx @@ -0,0 +1,15 @@ +const A = { fill: "none", stroke: "currentColor", "aria-hidden": true } as const; + +export const Chev = () => ; +export const Down = () => ; +export const Check = ({ size = 12 }: { size?: number }) => ; +export const Lock = () => ; +export const FileI = () => ; +export const DirI = () => ; +export const PdfI = () => ; +export const SearchI = () => ; +export const MenuI = () => ; +export const Prev = () => ; +export const Next = () => ; +export const Full = () => ; +export const DownloadI = () => ; diff --git a/packages/feature-learner/src/web/index.ts b/packages/feature-learner/src/web/index.ts new file mode 100644 index 0000000..4fcd26f --- /dev/null +++ b/packages/feature-learner/src/web/index.ts @@ -0,0 +1,17 @@ +import type { WebFeature } from "@stc/contracts"; + +export { LessonView, type LessonViewProps } from "./lesson/LessonView"; +export { ensureStyles as ensureLearnerStyles } from "./api"; + +/** Learner routes: library, course pages, lessons, notes and the account pages. */ +export const learnerWeb: WebFeature = { + name: "learner", + routes: [ + { path: "/", element: () => import("./library/LibraryPage") }, + { path: "/c/:courseId", element: () => import("./course/pages").then((m) => ({ default: m.CoursePage })) }, + { path: "/c/:courseId/notes", element: () => import("./course/pages").then((m) => ({ default: m.NotesPage })) }, + { path: "/c/:courseId/:lessonId", element: () => import("./course/pages").then((m) => ({ default: m.LessonPage })) }, + { path: "/account", element: () => import("./account/AccountPage") }, + { path: "/account/admin", element: () => import("./account/AdminPage") }, + ], +}; diff --git a/packages/feature-learner/src/web/lesson/CodeFiles.tsx b/packages/feature-learner/src/web/lesson/CodeFiles.tsx new file mode 100644 index 0000000..913ff2a --- /dev/null +++ b/packages/feature-learner/src/web/lesson/CodeFiles.tsx @@ -0,0 +1,155 @@ +import { useEffect, useMemo, useState } from "react"; +import { Pill } from "@stc/ui"; +import type { CodeListing, FileRef } from "../../shared/types"; +import { mediaUrl } from "../../shared/types"; +import { copyText, fmtSize, useApi } from "../api"; +import { DirI, DownloadI, FileI, PdfI } from "../icons"; + +const MAX_VIEW = 400 * 1024; +const BINARY = /\.(png|jpe?g|gif|webp|ico|pdf|zip|gz|tgz|jar|class|exe|dll|so|dylib|bin|mp4|mp3|wav|woff2?|ttf|otf|sqlite|db|pyc)$/i; + +export const zipUrl = (courseId: string, moduleId: string) => `/api/courses/${encodeURIComponent(courseId)}/code/${encodeURIComponent(moduleId)}/zip`; + +interface TreeNode { name: string; path: string; dirs: Map; files: { name: string; path: string; size: number }[] } + +export function buildTree(files: { path: string; size: number }[]): TreeNode { + const root: TreeNode = { name: "", path: "", dirs: new Map(), files: [] }; + for (const f of files) { + const parts = f.path.split("/"); + let node = root; + for (const p of parts.slice(0, -1)) { + if (!node.dirs.has(p)) node.dirs.set(p, { name: p, path: node.path ? `${node.path}/${p}` : p, dirs: new Map(), files: [] }); + node = node.dirs.get(p)!; + } + node.files.push({ name: parts[parts.length - 1]!, path: f.path, size: f.size }); + } + return root; +} + +/** Picks the first file to show: a README or the entry point, else the first text file. */ +function firstFile(files: { path: string; size: number }[]): string | null { + const text = files.filter((f) => !BINARY.test(f.path) && f.size <= MAX_VIEW); + const pref = [/(^|\/)dockerfile[^/]*$/i, /(^|\/)(main|index|app)\.[^/]+$/i, /(^|\/)readme(\.md)?$/i]; + for (const r of pref) { const f = text.find((x) => r.test(x.path)); if (f) return f.path; } + return text[0]?.path ?? null; +} + +/** + * Companion code for a section: file tree, tabs, line numbers, copy, the zip download and the command + * that runs it locally. Files are read through the media route, so visibility checks apply. + */ +export function CodeFiles({ courseId, moduleId, dir, title = "Files for this section" }: { courseId: string; moduleId: string; dir?: string; title?: string }) { + const { data: listing, error } = useApi(`/api/courses/${encodeURIComponent(courseId)}/code/${encodeURIComponent(moduleId)}`); + const [tabs, setTabs] = useState([]); + const [current, setCurrent] = useState(null); + const [content, setContent] = useState>({}); + useEffect(() => { + if (!listing) return; + const f = firstFile(listing.files); + setTabs(f ? [f] : []); setCurrent(f); setContent({}); + }, [listing]); + useEffect(() => { + if (!listing || !current || current in content) return; + const f = listing.files.find((x) => x.path === current); + if (!f) return; + if (BINARY.test(f.path) || f.size > MAX_VIEW) { setContent((c) => ({ ...c, [current]: "" })); return; } + let live = true; + fetch(mediaUrl(courseId, `${listing.dir}/${f.path}`), { credentials: "same-origin" }) + .then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status))))) + .then((t) => live && setContent((c) => ({ ...c, [current]: t })), () => live && setContent((c) => ({ ...c, [current]: "\u0000error" }))); + return () => { live = false; }; + }, [listing, current, content, courseId]); + const tree = useMemo(() => buildTree(listing?.files ?? []), [listing]); + + const folder = (listing?.dir ?? dir ?? "").split("/").filter(Boolean).pop() ?? moduleId; + const head = ( + + ); + if (error) return null; + if (!listing) return
{head}

Loading files…

; + const open = (p: string) => { setTabs((t) => (t.includes(p) ? t : [...t.slice(-5), p])); setCurrent(p); }; + const file = listing.files.find((f) => f.path === current); + const text = current ? content[current] : undefined; + const lines = text && !text.startsWith("\u0000") ? text.replace(/\n$/, "").split("\n") : null; + + return ( +
+ {head} + {listing.files.length === 0 ?

This folder is empty.

: ( +
+ +
+
+ {tabs.map((t) => ( + + ))} + {lines ? : null} +
+ {file ?
{folder}/{file.path} · {fmtSize(file.size)}
: null} + {!file ?

Pick a file on the left.

+ : text === undefined ?

Loading…

+ : lines ?
{lines.map((l, i) => {l || " "})}
+ :

{text === "" ? "This file is not shown here (binary or large). It is in the zip." : "This file could not be loaded."} Download it

} +
+
+ )} + {listing.command ? : null} +
+ ); +} + +function TreeLevel({ node, depth, current, onOpen }: { node: TreeNode; depth: number; current: string | null; onOpen: (p: string) => void }) { + const pad = { paddingLeft: 12 + depth * 14 }; + return ( + <> + {[...node.dirs.values()].map((d) => ( +
+
{d.name}
+ +
+ ))} + {node.files.map((f) => ( + + ))} + + ); +} + +export function CopyButton({ text, label = "Copy" }: { text: string; label?: string }) { + const [done, setDone] = useState(false); + return ( + { if (await copyText(text)) { setDone(true); setTimeout(() => setDone(false), 1400); } }}> + {done ? "Copied" : label} + + ); +} + +export function CommandBlock({ command, intro }: { command: string; intro?: string }) { + return ( +
+ {intro ?

{intro}

: null} +
{command}
+
+ ); +} + +export function Attachments({ files, title = "Attachments", note }: { files: FileRef[]; title?: string; note?: string }) { + if (!files.length) return null; + return ( +
+

{title}

{note ? {note} : null}
+
    + {files.map((f) => ( +
  • {f.title}{fmtSize(f.size)}
  • + ))} +
+
+ ); +} diff --git a/packages/feature-learner/src/web/lesson/DeckViewer.tsx b/packages/feature-learner/src/web/lesson/DeckViewer.tsx new file mode 100644 index 0000000..11d367a --- /dev/null +++ b/packages/feature-learner/src/web/lesson/DeckViewer.tsx @@ -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 | 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(null); + const [pages, setPages] = useState(0); + const [page, setPage] = useState(initialPage); + const [error, setError] = useState(null); + const stage = useRef(null); + const canvas = useRef(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 ( +
+
+ {error ? {error} Open the PDF : null} + {!error && !doc ? Loading slides… : null} +
+
+ go(page - 1)}> + {pages ? `${page} / ${pages}` : "– / –"} + = pages} onClick={() => go(page + 1)}> + + {keyboard ? Arrow keys change slides : null} + PDF + Fullscreen +
+ {doc ? : null} + {notes ? Number(n.anchorValue) === page)} /> : null} +
+ ); +} + +function Thumbs({ doc, pages, page, onPick, withNote }: { doc: PdfDoc; pages: number; page: number; onPick: (p: number) => void; withNote: Set }) { + const ref = useRef(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 ( +
+ {Array.from({ length: pages }, (_, i) => ( + + ))} +
+ ); +} + +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 ( +
+ +