Update dependencies in package.json and bun.lock, enhance README with curriculum contribution guidelines, and improve catalog structure with graph data for mathematics. Add new styles for chapter headers and catalog view toggles in SASS, and implement copy page button functionality in frontend components.

This commit is contained in:
Hananoshika Yomaru 2026-05-19 05:06:17 -07:00
parent 778b2093f8
commit 8c986c1ed7
64 changed files with 8435 additions and 106 deletions

View file

@ -1,5 +1,18 @@
import { createMemo, createSignal, For, Show } from "solid-js";
import type { CatalogChapter, CatalogData, CatalogStrand, CatalogSubject } from "../../../data/catalog.types";
import { createMemo, createSignal, For, lazy, onCleanup, onMount, Show, Suspense } from "solid-js";
import { subjectToCanvas } from "../lib/catalog-to-canvas";
import { watchSiteTheme } from "../lib/catalog-canvas-theme";
import { CatalogChapterCard } from "./catalog-chapter-card";
import type {
CatalogChapter,
CatalogData,
CatalogStrand,
CatalogSubject,
CatalogViewMode,
} from "../../../data/catalog.types";
const CatalogCanvasView = lazy(() =>
import("./catalog-canvas-view").then((m) => ({ default: m.CatalogCanvasView }))
);
function readCatalogData(): CatalogData | null {
const el = document.getElementById("catalog-data");
@ -16,15 +29,9 @@ function initialSubjectId(subjects: CatalogSubject[]): string {
return subjects[0]?.id ?? "math";
}
function curriculumBadgeClass(curriculum: string): string {
const map: Record<string, string> = {
DSE: "catalog-badge--dse",
IB: "catalog-badge--ib",
"A-Level": "catalog-badge--a-level",
AP: "catalog-badge--ap",
IGCSE: "catalog-badge--igcse",
};
return map[curriculum] ?? "catalog-badge--default";
function initialViewMode(): CatalogViewMode {
const params = new URLSearchParams(window.location.search);
return params.get("view") === "tree" ? "tree" : "linear";
}
function chapterMatchesFilters(chapter: CatalogChapter, activeFilters: Set<string>): boolean {
@ -38,9 +45,14 @@ function countVisibleChapters(subject: CatalogSubject, activeFilters: Set<string
}, 0);
}
function syncSubjectUrl(subjectId: string) {
function syncUrl(subjectId: string, viewMode: CatalogViewMode) {
const url = new URL(window.location.href);
url.searchParams.set("subject", subjectId);
if (viewMode === "tree") {
url.searchParams.set("view", "tree");
} else {
url.searchParams.delete("view");
}
window.history.replaceState(null, "", url);
}
@ -52,6 +64,17 @@ export function CatalogApp() {
const [currentSubjectId, setCurrentSubjectId] = createSignal(initialSubjectId(data.subjects));
const [activeFilters, setActiveFilters] = createSignal<Set<string>>(new Set());
const [viewMode, setViewMode] = createSignal<CatalogViewMode>(initialViewMode());
const [canvasThemeTick, setCanvasThemeTick] = createSignal(0);
const [mapLayoutReady, setMapLayoutReady] = createSignal(!document.fonts);
onMount(() => {
const stopThemeWatch = watchSiteTheme(() => setCanvasThemeTick((tick) => tick + 1));
if (document.fonts) {
void document.fonts.ready.then(() => setMapLayoutReady(true));
}
onCleanup(stopThemeWatch);
});
const currentSubject = createMemo(
() => data.subjects.find((s) => s.id === currentSubjectId()) ?? data.subjects[0]
@ -61,6 +84,28 @@ export function CatalogApp() {
const hasChapterTree = createMemo(() => currentSubject().strands.length > 0);
const chapterFilter = createMemo(
() => (chapter: CatalogChapter) => chapterMatchesFilters(chapter, activeFilters())
);
const canvasData = createMemo(() => {
canvasThemeTick();
if (!mapLayoutReady()) return undefined;
return subjectToCanvas(currentSubject(), chapterFilter());
});
const visibleChapters = createMemo(() => {
const map: Record<string, CatalogChapter> = {};
for (const strand of currentSubject().strands) {
for (const chapter of strand.chapters) {
if (chapterFilter()(chapter)) {
map[chapter.slug] = chapter;
}
}
}
return map;
});
const statsLabel = createMemo(() => {
const count = visibleCount();
const filters = activeFilters();
@ -71,7 +116,12 @@ export function CatalogApp() {
const selectSubject = (subjectId: string) => {
setCurrentSubjectId(subjectId);
syncSubjectUrl(subjectId);
syncUrl(subjectId, viewMode());
};
const setCatalogView = (mode: CatalogViewMode) => {
setViewMode(mode);
syncUrl(currentSubjectId(), mode);
};
const toggleFilter = (curriculum: string) => {
@ -129,34 +179,59 @@ export function CatalogApp() {
<h1 class="catalog-header__title">{currentSubject().name}</h1>
<p class="catalog-header__stats">{statsLabel()}</p>
</div>
<div class="catalog-filters-wrap">
<p class="catalog-filters__label">Filter by curriculum</p>
<div class="catalog-filters" role="group" aria-label="Curriculum filters">
<div class="catalog-header__controls">
<div class="catalog-view-toggle" role="tablist" aria-label="Chapter layout">
<button
type="button"
class="catalog-filter-btn"
classList={{ "is-active": activeFilters().size === 0 }}
onClick={() => toggleFilter("ALL")}
class="catalog-view-toggle__btn"
role="tab"
aria-selected={viewMode() === "linear" ? "true" : "false"}
onClick={() => setCatalogView("linear")}
>
All
List
</button>
<For each={data.curriculums}>
{(curriculum) => (
<button
type="button"
class="catalog-filter-btn"
classList={{ "is-active": activeFilters().has(curriculum) }}
onClick={() => toggleFilter(curriculum)}
>
{curriculum}
</button>
)}
</For>
<button
type="button"
class="catalog-view-toggle__btn"
role="tab"
aria-selected={viewMode() === "tree" ? "true" : "false"}
onClick={() => setCatalogView("tree")}
>
Map
</button>
</div>
<div class="catalog-filters-wrap">
<p class="catalog-filters__label">Filter by curriculum</p>
<div class="catalog-filters" role="group" aria-label="Curriculum filters">
<button
type="button"
class="catalog-filter-btn"
classList={{ "is-active": activeFilters().size === 0 }}
onClick={() => toggleFilter("ALL")}
>
All
</button>
<For each={data.curriculums}>
{(curriculum) => (
<button
type="button"
class="catalog-filter-btn"
classList={{ "is-active": activeFilters().has(curriculum) }}
onClick={() => toggleFilter(curriculum)}
>
{curriculum}
</button>
)}
</For>
</div>
</div>
</div>
</header>
<div class="catalog-content">
<div
class="catalog-content"
classList={{ "catalog-content--tree": viewMode() === "tree" }}
>
<Show
when={hasChapterTree() && visibleCount() > 0}
fallback={
@ -172,64 +247,55 @@ export function CatalogApp() {
</div>
}
>
<For each={filteredStrands()}>
{(strand: CatalogStrand) => (
<section class="catalog-strand" aria-labelledby={`strand-${strand.id}`}>
<h2 id={`strand-${strand.id}`} class="catalog-strand__title">
<span class="catalog-strand__marker" aria-hidden="true">
✦
</span>
{strand.title}
</h2>
<ol class="catalog-tree">
<For each={strand.chapters}>
{(chapter, index) => (
<li class="catalog-tree__item">
<ChapterCard chapter={chapter} index={index()} />
</li>
)}
</For>
</ol>
</section>
)}
</For>
<Show
when={viewMode() === "tree"}
fallback={
<For each={filteredStrands()}>
{(strand: CatalogStrand) => (
<section class="catalog-strand" aria-labelledby={`strand-${strand.id}`}>
<h2 id={`strand-${strand.id}`} class="catalog-strand__title">
<span class="catalog-strand__marker" aria-hidden="true">
✦
</span>
{strand.title}
</h2>
<ol class="catalog-tree">
<For each={strand.chapters}>
{(chapter, index) => (
<li class="catalog-tree__item">
<CatalogChapterCard
chapter={chapter}
index={index()}
subjectId={currentSubjectId()}
variant="list"
/>
</li>
)}
</For>
</ol>
</section>
)}
</For>
}
>
<Show
when={canvasData()}
fallback={<p class="catalog-canvas-loading">Loading chapter map…</p>}
>
{(canvas) => (
<Suspense fallback={<p class="catalog-canvas-loading">Loading chapter map…</p>}>
<CatalogCanvasView
canvas={canvas()}
subjectId={currentSubjectId()}
chapters={visibleChapters()}
/>
</Suspense>
)}
</Show>
</Show>
</Show>
</div>
</div>
</div>
);
}
function ChapterCard(props: { chapter: CatalogChapter; index: number }) {
const number = () => String(props.index + 1).padStart(2, "0");
const inner = (
<>
<div class="catalog-chapter-card__main">
<span class="catalog-chapter-card__number">{number()}</span>
<span class="catalog-chapter-card__title">{props.chapter.title}</span>
</div>
<div class="catalog-chapter-card__meta">
<For each={props.chapter.curriculums}>
{(curriculum) => (
<span class={`catalog-badge ${curriculumBadgeClass(curriculum)}`}>{curriculum}</span>
)}
</For>
<Show when={props.chapter.status === "planned"}>
<span class="book-chapter-badge book-chapter-badge--planned">Coming soon</span>
</Show>
</div>
</>
);
return (
<Show
when={props.chapter.status === "live"}
fallback={<div class="catalog-chapter-card catalog-chapter-card--planned">{inner}</div>}
>
<a class="catalog-chapter-card" href={`/math/${props.chapter.slug}/`}>
{inner}
</a>
</Show>
);
}

View file

@ -0,0 +1,181 @@
import { createEffect, onCleanup, onMount } from "solid-js";
import type {
JSONCanvas,
JSONCanvasTextNode,
JSONCanvasViewerInterface,
} from "json-canvas-viewer";
import {
JSONCanvasViewer,
MistouchPreventer,
Controls,
Minimap,
parser,
} from "json-canvas-viewer";
import type { CatalogChapter } from "../../../data/catalog.types";
import { renderCatalogChapterCardElement } from "./catalog-chapter-card";
import { chapterSlugFromNodeId, relayoutCanvasChapterHeights } from "../lib/catalog-to-canvas";
import { measureRenderedMapChapterCards } from "../lib/catalog-chapter-card-layout";
import {
syncViewerTheme,
VIEWER_THEME_COLORS,
siteCanvasTheme,
watchSiteTheme,
} from "../lib/catalog-canvas-theme";
type CatalogCanvasViewProps = {
canvas: JSONCanvas;
subjectId: string;
chapters: Record<string, CatalogChapter>;
};
function serializeCanvas(canvas: JSONCanvas): string {
return JSON.stringify(canvas);
}
function waitForLayout(el: HTMLElement): Promise<void> {
return new Promise((resolve) => {
const check = () => {
if (el.clientWidth >= 1 && el.clientHeight >= 1) {
resolve();
return;
}
requestAnimationFrame(check);
};
check();
});
}
function bindPageScrollWheel(container: HTMLElement): () => void {
const onWheel = (event: WheelEvent) => {
if (event.ctrlKey || event.metaKey) return;
event.stopPropagation();
};
container.addEventListener("wheel", onWheel, { capture: true, passive: true });
return () => container.removeEventListener("wheel", onWheel, { capture: true });
}
function waitForFrames(count = 2): Promise<void> {
return new Promise((resolve) => {
let remaining = count;
const step = () => {
remaining -= 1;
if (remaining <= 0) {
resolve();
return;
}
requestAnimationFrame(step);
};
requestAnimationFrame(step);
});
}
function buildNodeComponents(
subjectId: string,
chapters: Record<string, CatalogChapter>
) {
return {
text: ({
container,
node,
}: {
container: HTMLDivElement;
node: JSONCanvasTextNode;
}) => {
const slug = chapterSlugFromNodeId(node.id);
if (!slug) return;
const chapter = chapters[slug];
if (!chapter) return;
container.replaceChildren();
container.appendChild(renderCatalogChapterCardElement(chapter, subjectId, "map"));
},
};
}
export function CatalogCanvasView(props: CatalogCanvasViewProps) {
let container!: HTMLDivElement;
let viewer: JSONCanvasViewerInterface | undefined;
let lastSerialized = "";
let loadGeneration = 0;
let unbindWheel: (() => void) | undefined;
const chaptersKey = (chapters: Record<string, CatalogChapter>) =>
JSON.stringify(
Object.entries(chapters).map(([slug, ch]) => [slug, ch.title, ch.description, ch.status])
);
const loadCanvas = async (canvas: JSONCanvas, chapters: Record<string, CatalogChapter>) => {
const cacheKey = `${serializeCanvas(canvas)}|${chaptersKey(chapters)}`;
if (!viewer || cacheKey === lastSerialized) return;
const generation = ++loadGeneration;
await waitForLayout(container);
if (generation !== loadGeneration || !viewer) return;
lastSerialized = cacheKey;
viewer.load({ canvas });
syncViewerTheme(viewer);
await waitForFrames();
if (generation !== loadGeneration || !viewer) return;
const measured = measureRenderedMapChapterCards(container);
const fitted = relayoutCanvasChapterHeights(canvas, measured);
if (!fitted) return;
const fittedKey = `${serializeCanvas(fitted)}|${chaptersKey(chapters)}`;
if (fittedKey === lastSerialized) return;
lastSerialized = fittedKey;
viewer.load({ canvas: fitted });
syncViewerTheme(viewer);
};
onMount(() => {
let stopThemeWatch: (() => void) | undefined;
unbindWheel = bindPageScrollWheel(container);
viewer = new JSONCanvasViewer(
{
container,
parser,
theme: siteCanvasTheme(),
colors: VIEWER_THEME_COLORS,
loading: "none",
preventMistouchAtStart: false,
mistouchPreventerBannerText: "Click the map to pan and zoom",
minimapCollapsed: true,
nodeComponents: buildNodeComponents(props.subjectId, props.chapters),
},
[MistouchPreventer, Controls, Minimap]
);
stopThemeWatch = watchSiteTheme(() => syncViewerTheme(viewer));
void loadCanvas(props.canvas, props.chapters);
onCleanup(() => {
loadGeneration += 1;
stopThemeWatch?.();
unbindWheel?.();
unbindWheel = undefined;
viewer?.dispose();
viewer = undefined;
lastSerialized = "";
});
});
createEffect(() => {
if (!viewer) return;
viewer.options.nodeComponents = buildNodeComponents(
props.subjectId,
props.chapters
);
void loadCanvas(props.canvas, props.chapters);
});
return (
<div ref={container} class="catalog-canvas-view" role="application" aria-label="Chapter map" />
);
}

View file

@ -0,0 +1,189 @@
import { For, Show } from "solid-js";
import type { CatalogChapter } from "../../../data/catalog.types";
import { curriculumBadgeClass } from "../lib/catalog-badge";
export type CatalogChapterCardProps = {
chapter: CatalogChapter;
subjectId: string;
index?: number;
variant?: "list" | "map";
};
function chapterHref(subjectId: string, slug: string): string {
return `/${subjectId}/${slug}/`;
}
function appendMeta(parent: HTMLElement, chapter: CatalogChapter) {
const meta = document.createElement("div");
meta.className = "catalog-chapter-card__meta";
for (const curriculum of chapter.curriculums) {
const badge = document.createElement("span");
badge.className = `catalog-badge ${curriculumBadgeClass(curriculum)}`;
badge.textContent = curriculum;
meta.appendChild(badge);
}
if (chapter.status === "planned") {
const soon = document.createElement("span");
soon.className = "book-chapter-badge book-chapter-badge--planned";
soon.textContent = "Coming soon";
meta.appendChild(soon);
}
parent.appendChild(meta);
}
function appendMapTitle(
body: HTMLElement,
chapter: CatalogChapter,
subjectId: string
) {
const heading = document.createElement("h3");
heading.className = "catalog-chapter-card__title";
if (chapter.status === "live") {
const link = document.createElement("a");
link.className = "catalog-chapter-card__title-link";
link.href = chapterHref(subjectId, chapter.slug);
link.addEventListener("click", (event) => event.stopPropagation());
const text = document.createElement("span");
text.className = "catalog-chapter-card__title-text";
text.textContent = chapter.title;
const arrow = document.createElement("span");
arrow.className = "catalog-chapter-card__title-arrow";
arrow.setAttribute("aria-hidden", "true");
arrow.textContent = "→";
link.append(text, arrow);
heading.appendChild(link);
} else {
heading.textContent = chapter.title;
}
body.appendChild(heading);
}
/** DOM builder for json-canvas-viewer `nodeComponents.text` (same markup/classes as list view). */
export function renderCatalogChapterCardElement(
chapter: CatalogChapter,
subjectId: string,
variant: "list" | "map" = "map"
): HTMLElement {
const root = document.createElement("div");
root.className = [
"catalog-chapter-card",
variant === "map" ? "catalog-chapter-card--map" : "",
chapter.status === "planned" ? "catalog-chapter-card--planned" : "",
]
.filter(Boolean)
.join(" ");
root.dataset.chapterSlug = chapter.slug;
const body = document.createElement("div");
body.className = "catalog-chapter-card__body";
if (variant === "map") {
appendMapTitle(body, chapter, subjectId);
} else {
const title = document.createElement("h3");
title.className = "catalog-chapter-card__title";
title.textContent = chapter.title;
body.appendChild(title);
}
if (chapter.description) {
const description = document.createElement("p");
description.className = "catalog-chapter-card__description";
description.textContent = chapter.description;
body.appendChild(description);
}
appendMeta(body, chapter);
root.appendChild(body);
return root;
}
function MapChapterTitle(props: {
chapter: CatalogChapter;
subjectId: string;
}) {
return (
<h3 class="catalog-chapter-card__title">
<Show
when={props.chapter.status === "live"}
fallback={props.chapter.title}
>
<a class="catalog-chapter-card__title-link" href={chapterHref(props.subjectId, props.chapter.slug)}>
<span class="catalog-chapter-card__title-text">{props.chapter.title}</span>
<span class="catalog-chapter-card__title-arrow" aria-hidden="true">
→
</span>
</a>
</Show>
</h3>
);
}
export function CatalogChapterCard(props: CatalogChapterCardProps) {
const variant = () => props.variant ?? "list";
const href = () => chapterHref(props.subjectId, props.chapter.slug);
const number = () =>
props.index === undefined ? undefined : String(props.index + 1).padStart(2, "0");
const inner = (
<div class="catalog-chapter-card__body">
<Show when={variant() === "list" && number()}>
<div class="catalog-chapter-card__main">
<span class="catalog-chapter-card__number">{number()}</span>
<h3 class="catalog-chapter-card__title">{props.chapter.title}</h3>
</div>
</Show>
<Show when={variant() === "map"}>
<MapChapterTitle chapter={props.chapter} subjectId={props.subjectId} />
</Show>
<Show when={props.chapter.description}>
<p class="catalog-chapter-card__description">{props.chapter.description}</p>
</Show>
<div class="catalog-chapter-card__meta">
<For each={props.chapter.curriculums}>
{(curriculum) => (
<span class={`catalog-badge ${curriculumBadgeClass(curriculum)}`}>{curriculum}</span>
)}
</For>
<Show when={props.chapter.status === "planned"}>
<span class="book-chapter-badge book-chapter-badge--planned">Coming soon</span>
</Show>
</div>
</div>
);
return (
<Show
when={props.chapter.status === "live" && variant() === "list"}
fallback={
<div
class="catalog-chapter-card"
classList={{
"catalog-chapter-card--map": variant() === "map",
"catalog-chapter-card--planned": props.chapter.status === "planned",
}}
data-chapter-slug={props.chapter.slug}
>
{inner}
</div>
}
>
<a
class="catalog-chapter-card"
href={href()}
data-chapter-slug={props.chapter.slug}
>
{inner}
</a>
</Show>
);
}

View file

@ -0,0 +1,97 @@
import { createSignal } from "solid-js";
import { copyPageAsMarkdown } from "../lib/copy-page-as-markdown";
type ButtonState = "idle" | "loading" | "copied" | "error";
const COPIED_RESET_MS = 2000;
function CopyIcon() {
return (
<svg
class="copy-page-button__icon"
width="16"
height="16"
viewBox="0 0 16 16"
aria-hidden="true"
>
<rect x="5" y="5" width="9" height="9" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.25" />
<path
d="M3 11V3.5A1.5 1.5 0 0 1 4.5 2H11"
fill="none"
stroke="currentColor"
stroke-width="1.25"
stroke-linecap="round"
/>
</svg>
);
}
export function CopyPageButton() {
const [state, setState] = createSignal<ButtonState>("idle");
const [errorMessage, setErrorMessage] = createSignal("");
const label = () => {
switch (state()) {
case "loading":
return "Preparing…";
case "copied":
return "Copied!";
case "error":
return "Copy failed";
default:
return "Copy page";
}
};
const handleClick = async () => {
if (state() === "loading") {
return;
}
setState("loading");
setErrorMessage("");
try {
await copyPageAsMarkdown();
setState("copied");
window.setTimeout(() => {
if (state() === "copied") {
setState("idle");
}
}, COPIED_RESET_MS);
} catch (err) {
setState("error");
setErrorMessage(err instanceof Error ? err.message : "Could not copy page.");
window.setTimeout(() => {
if (state() === "error") {
setState("idle");
setErrorMessage("");
}
}, COPIED_RESET_MS);
}
};
return (
<div class="copy-page-button-wrap">
<button
type="button"
class="copy-page-button"
classList={{
"copy-page-button--loading": state() === "loading",
"copy-page-button--copied": state() === "copied",
"copy-page-button--error": state() === "error",
}}
onClick={handleClick}
disabled={state() === "loading"}
aria-label={label()}
title="Copy page as Markdown for LLMs"
>
<CopyIcon />
<span class="copy-page-button__label">{label()}</span>
</button>
<p class="copy-page-button__status" aria-live="polite">
{state() === "error" ? errorMessage() : state() === "copied" ? "Page copied to clipboard." : ""}
</p>
</div>
);
}