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]>
198 lines
8.3 KiB
TypeScript
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;
|
|
}
|
|
}
|