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