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

198 lines
8.3 KiB
TypeScript

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<T>(path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
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<T>(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<T>(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<T>(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<T>(s: ReturnType<typeof store<T>>): 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<Look>((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<void> | null = null;
export function refreshMe(): Promise<void> {
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<LibraryPayload | null>(null);
let libraryLoading: Promise<void> | null = null;
export function refreshLibrary(): Promise<void> {
libraryLoading = api<LibraryPayload>("/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<string, CoursePayload>();
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<void> } {
useStore(courseVersion);
const [error, setError] = useState<HttpError | null>(null);
const live = useRef(true);
const reload = useCallback(async () => {
try {
const c = await api<CoursePayload>(`/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<string | null>(null);
let toastTimer: ReturnType<typeof setTimeout> | 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<boolean> {
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;
}
}