Update navigation URLs in zola.toml and main.tsx to reflect new catalog structure, enhance math curriculum JSON with curriculum identifiers, and integrate catalog styles in SASS. Improve particle background generation logic and visual effects in particle-background.ts.

This commit is contained in:
Hananoshika Yomaru 2026-05-19 03:40:53 -07:00
parent 07a76333bd
commit 992275cc29
21 changed files with 34810 additions and 35 deletions

4
content/catalog.md Normal file
View file

@ -0,0 +1,4 @@
+++
title = "Catalog"
template = "catalog.html"
+++

11
data/catalog.json Normal file
View file

@ -0,0 +1,11 @@
{
"title": "Curriculum Catalog",
"subtitle": "Browse chapters by subject and curriculum",
"curriculums": ["DSE", "IB", "A-Level", "AP", "IGCSE"],
"subjects": [
{ "id": "math", "name": "Mathematics" },
{ "id": "science", "name": "Science" },
{ "id": "humanities", "name": "Humanities" },
{ "id": "computing", "name": "Computing" }
]
}

25
data/catalog.types.ts Normal file
View file

@ -0,0 +1,25 @@
export type CatalogChapter = {
slug: string;
title: string;
status: "live" | "planned";
curriculums: string[];
};
export type CatalogStrand = {
id: string;
title: string;
chapters: CatalogChapter[];
};
export type CatalogSubject = {
id: string;
name: string;
strands: CatalogStrand[];
};
export type CatalogData = {
title: string;
subtitle: string;
curriculums: string[];
subjects: CatalogSubject[];
};

View file

@ -10,22 +10,26 @@
{
"slug": "quadratic-equations",
"title": "Quadratic equations",
"status": "live"
"status": "live",
"curriculums": ["DSE"]
},
{
"slug": "sequences",
"title": "Sequences & series",
"status": "planned"
"status": "planned",
"curriculums": ["DSE"]
},
{
"slug": "functions-graphs",
"title": "Functions & graphs",
"status": "planned"
"status": "planned",
"curriculums": ["DSE"]
},
{
"slug": "linear-programming",
"title": "Linear programming",
"status": "planned"
"status": "planned",
"curriculums": ["DSE"]
}
]
},
@ -36,22 +40,26 @@
{
"slug": "trigonometry",
"title": "Advanced trigonometry",
"status": "planned"
"status": "planned",
"curriculums": ["DSE"]
},
{
"slug": "coordinate-geometry",
"title": "Coordinate geometry of circles",
"status": "planned"
"status": "planned",
"curriculums": ["DSE"]
},
{
"slug": "circle-properties",
"title": "Properties of circles",
"status": "planned"
"status": "planned",
"curriculums": ["DSE"]
},
{
"slug": "loci",
"title": "Loci",
"status": "planned"
"status": "planned",
"curriculums": ["DSE"]
}
]
},
@ -62,22 +70,26 @@
{
"slug": "dispersion",
"title": "Measures of dispersion",
"status": "planned"
"status": "planned",
"curriculums": ["DSE"]
},
{
"slug": "probability",
"title": "Permutations, combinations & probability",
"status": "planned"
"status": "planned",
"curriculums": ["DSE"]
},
{
"slug": "normal-distribution",
"title": "Normal distribution & z-scores",
"status": "planned"
"status": "planned",
"curriculums": ["DSE"]
},
{
"slug": "statistical-literacy",
"title": "Statistical literacy",
"status": "planned"
"status": "planned",
"curriculums": ["DSE"]
}
]
}

View file

@ -0,0 +1,235 @@
import { createMemo, createSignal, For, Show } from "solid-js";
import type { CatalogChapter, CatalogData, CatalogStrand, CatalogSubject } from "../../../data/catalog.types";
function readCatalogData(): CatalogData | null {
const el = document.getElementById("catalog-data");
if (!el?.textContent) return null;
return JSON.parse(el.textContent) as CatalogData;
}
function initialSubjectId(subjects: CatalogSubject[]): string {
const params = new URLSearchParams(window.location.search);
const fromUrl = params.get("subject");
if (fromUrl && subjects.some((s) => s.id === fromUrl)) {
return fromUrl;
}
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 chapterMatchesFilters(chapter: CatalogChapter, activeFilters: Set<string>): boolean {
if (activeFilters.size === 0) return true;
return chapter.curriculums.some((c) => activeFilters.has(c));
}
function countVisibleChapters(subject: CatalogSubject, activeFilters: Set<string>): number {
return subject.strands.reduce((total, strand) => {
return total + strand.chapters.filter((ch) => chapterMatchesFilters(ch, activeFilters)).length;
}, 0);
}
function syncSubjectUrl(subjectId: string) {
const url = new URL(window.location.href);
url.searchParams.set("subject", subjectId);
window.history.replaceState(null, "", url);
}
export function CatalogApp() {
const data = readCatalogData();
if (!data) {
return <p class="catalog-error">Catalog data could not be loaded.</p>;
}
const [currentSubjectId, setCurrentSubjectId] = createSignal(initialSubjectId(data.subjects));
const [activeFilters, setActiveFilters] = createSignal<Set<string>>(new Set());
const currentSubject = createMemo(
() => data.subjects.find((s) => s.id === currentSubjectId()) ?? data.subjects[0]
);
const visibleCount = createMemo(() => countVisibleChapters(currentSubject(), activeFilters()));
const hasChapterTree = createMemo(() => currentSubject().strands.length > 0);
const statsLabel = createMemo(() => {
const count = visibleCount();
const filters = activeFilters();
const filterText =
filters.size === 0 ? "all" : `filtered (${Array.from(filters).join(", ")})`;
return `Showing ${count} ${filterText} chapter${count === 1 ? "" : "s"}`;
});
const selectSubject = (subjectId: string) => {
setCurrentSubjectId(subjectId);
syncSubjectUrl(subjectId);
};
const toggleFilter = (curriculum: string) => {
if (curriculum === "ALL") {
setActiveFilters(new Set<string>());
return;
}
setActiveFilters((prev) => {
const next = new Set(prev);
if (next.has(curriculum)) {
next.delete(curriculum);
} else {
next.add(curriculum);
}
return next;
});
};
const filteredStrands = createMemo(() => {
const subject = currentSubject();
return subject.strands
.map((strand) => ({
...strand,
chapters: strand.chapters.filter((ch) => chapterMatchesFilters(ch, activeFilters())),
}))
.filter((strand) => strand.chapters.length > 0);
});
return (
<div class="catalog-shell">
<aside class="catalog-sidebar" aria-label="Subjects">
<div class="catalog-sidebar__header">
<p class="catalog-sidebar__kicker">{data.title}</p>
<p class="catalog-sidebar__subtitle">{data.subtitle}</p>
</div>
<nav class="catalog-sidebar__nav" aria-label="Subject navigation">
<For each={data.subjects}>
{(subject) => (
<button
type="button"
class="catalog-subject-btn"
classList={{ "is-active": currentSubjectId() === subject.id }}
onClick={() => selectSubject(subject.id)}
>
{subject.name}
</button>
)}
</For>
</nav>
</aside>
<div class="catalog-main">
<header class="catalog-header">
<div class="catalog-header__intro">
<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">
<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>
</header>
<div class="catalog-content">
<Show
when={hasChapterTree() && visibleCount() > 0}
fallback={
<div class="catalog-empty">
<h2 class="catalog-empty__title">
{hasChapterTree() ? "No chapters found" : "Coming soon"}
</h2>
<p class="catalog-empty__text">
{hasChapterTree()
? "There are no chapters in this subject matching your selected curriculum filters. Try selecting All to see everything."
: "Chapters for this subject are on the roadmap. Mathematics is available now — select it from the sidebar."}
</p>
</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>
</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

@ -178,17 +178,55 @@ type TreeBranch = {
ex: number;
ey: number;
ez: number;
length: number;
thickStart: number;
thickEnd: number;
isTerminal: boolean;
};
/** Volume proxy for even density: longer / thicker segments get more particles. */
function segmentParticleWeight(length: number, thickStart: number, thickEnd: number) {
const avgThick = (thickStart + thickEnd) * 0.5;
return Math.max(0.001, length * avgThick * avgThick);
}
function branchParticleWeight(branch: TreeBranch) {
return segmentParticleWeight(branch.length, branch.thickStart, branch.thickEnd);
}
function rootParticleWeight(segment: RootSegment) {
return segmentParticleWeight(segment.length, segment.thickStart, segment.thickEnd);
}
function buildWeightCdf<T>(items: T[], weight: (item: T) => number) {
const cdf: number[] = [];
let total = 0;
for (const item of items) {
total += weight(item);
cdf.push(total);
}
return { cdf, total };
}
function pickWeighted<T>(items: T[], cdf: number[], total: number) {
const target = Math.random() * total;
let lo = 0;
let hi = cdf.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (cdf[mid] < target) lo = mid + 1;
else hi = mid;
}
return items[lo];
}
type RootSegment = {
sx: number;
sz: number;
ex: number;
ez: number;
bend: number;
length: number;
thickStart: number;
thickEnd: number;
depth: number;
@ -260,11 +298,11 @@ function generateDetailedTree(
isFlower: Float32Array,
palette: ThemePalette
) {
const landCount = Math.floor(count * (25000 / 150000));
const landCount = Math.floor(count * (30000 / 150000));
const rootCount = Math.floor(count * (15000 / 150000));
const trunkCount = Math.floor(count * (3000 / 150000));
const branchParticleCount = Math.floor(count * (55000 / 150000));
const junctionBudget = Math.floor(count * (4000 / 150000));
const trunkCount = Math.floor(count * (6000 / 150000));
const branchParticleCount = Math.floor(count * (43000 / 150000));
const junctionBudget = Math.floor(count * (7000 / 150000));
const flowerPlotCount = Math.max(120, Math.floor(count * (1000 / 150000)));
let pIdx = 0;
@ -294,7 +332,7 @@ function generateDetailedTree(
const ex = sx + Math.cos(angle) * length;
const ez = sz + Math.sin(angle) * length;
const thickEnd = Math.max(0.018, thickStart * (0.42 + Math.random() * 0.15));
rootSegments.push({ sx, sz, ex, ez, bend: curve, thickStart, thickEnd, depth });
rootSegments.push({ sx, sz, ex, ez, bend: curve, length, thickStart, thickEnd, depth });
if (depth >= maxDepth) return;
@ -331,8 +369,10 @@ function generateDetailedTree(
);
}
const rootPick = buildWeightCdf(rootSegments, rootParticleWeight);
for (let i = 0; i < rootCount && pIdx < count; i += 1, pIdx += 1) {
const segment = rootSegments[Math.floor(Math.random() * rootSegments.length)];
const segment = pickWeighted(rootSegments, rootPick.cdf, rootPick.total);
const t = Math.pow(Math.random(), 0.82);
const omt = 1 - t;
const midX = (segment.sx + segment.ex) * 0.5 + Math.cos(Math.atan2(segment.ez - segment.sz, segment.ex - segment.sx) + Math.PI / 2) * segment.bend;
@ -360,7 +400,12 @@ function generateDetailedTree(
const fx = r * Math.cos(theta);
const fz = r * Math.sin(theta);
const fh = 0.35 + Math.random() * 0.55;
const magenta = Math.random() > 0.5;
const flowerVariant = Math.floor(Math.random() * 3);
const flowerColors: [number, number, number][] = [
[0.95, 0.14, 0.2], // red
[0.72, 0.2, 0.98], // purple
[1, 0.88, 0.14], // yellow
];
const stemSegments = 8;
for (let j = 0; j < stemSegments; j += 1) {
@ -375,12 +420,10 @@ function generateDetailedTree(
positions[pIdx * 3] = fx;
positions[pIdx * 3 + 1] = GROUND_Y + fh;
positions[pIdx * 3 + 2] = fz;
if (magenta) {
setColorExact(colors, pIdx, 1, 0.2, 0.8);
} else {
setColorExact(colors, pIdx, 1, 0.9, 0.2);
}
isFlower[pIdx] = 1;
const [fr, fg, fb] = flowerColors[flowerVariant];
setColorExact(colors, pIdx, fr, fg, fb);
// 1 = red, 2 = purple, 3 = yellow (0 = not a flower)
isFlower[pIdx] = flowerVariant + 1;
pIdx += 1;
}
@ -425,6 +468,7 @@ function generateDetailedTree(
ex,
ey,
ez,
length,
thickStart,
thickEnd,
isTerminal,
@ -508,9 +552,12 @@ function generateDetailedTree(
const branchOrange: ShapeColors = palette.helix;
const branchOrangeTip: ShapeColors = palette.sphere;
const branchPick = buildWeightCdf(branches, branchParticleWeight);
const terminalPick =
terminalBranches.length > 0 ? buildWeightCdf(terminalBranches, branchParticleWeight) : null;
for (let i = 0; i < branchParticleCount && pIdx < count; i += 1, pIdx += 1) {
const b = branches[Math.floor(Math.random() * branches.length)];
const b = pickWeighted(branches, branchPick.cdf, branchPick.total);
const t =
Math.random() < 0.45
? Math.pow(Math.random(), 2.2) * 0.35
@ -526,8 +573,8 @@ function generateDetailedTree(
}
while (pIdx < count) {
if (terminalBranches.length === 0) break;
const b = terminalBranches[Math.floor(Math.random() * terminalBranches.length)];
if (!terminalPick) break;
const b = pickWeighted(terminalBranches, terminalPick.cdf, terminalPick.total);
const t = Math.pow(Math.random(), 0.6);
const pos = evalBezier(b.sx, b.sy, b.sz, b.cx, b.cy, b.cz, b.ex, b.ey, b.ez, t);
const angle = Math.random() * Math.PI * 2;
@ -590,8 +637,10 @@ const vertexShader = `
varying vec3 vColor;
varying float vAlpha;
varying float vFlowerGlint;
void main() {
vFlowerGlint = 0.0;
float m = clamp(uMorph, 0.0, ${MORPH_MAX}.0);
float t = fract(m);
float ease = t < 0.5 ? 4.0 * t * t * t : 1.0 - pow(-2.0 * t + 2.0, 3.0) / 2.0;
@ -630,10 +679,21 @@ const vertexShader = `
}
if (aIsFlower > 0.5) {
float blink = 0.5 + 0.5 * sin(uTime * 5.0 + pos4.x * 12.0 + pos4.z * 10.0);
finalPos.y += blink * 0.35 * treeInfluence;
finalCol += finalCol * blink * 2.5 * treeInfluence;
float flowerKind = floor(aIsFlower + 0.1);
float phase = uTime * 7.0 + pos4.x * 19.0 + pos4.z * 14.0 + flowerKind * 2.4;
float twinkle = 0.5 + 0.5 * sin(phase);
float sparkle = pow(max(0.0, sin(phase * 3.1)), 10.0);
float shimmer = 0.5 + twinkle * 0.45 + sparkle * 1.1;
finalPos.y += twinkle * 0.28 * treeInfluence;
finalCol *= shimmer;
finalCol += vec3(1.0, 0.96, 0.88) * sparkle * 1.35 * treeInfluence;
vFlowerGlint = (sparkle * 0.95 + twinkle * 0.35) * treeInfluence;
} else {
vFlowerGlint = 0.0;
}
} else {
vFlowerGlint = 0.0;
}
float sphereInfluence = max(0.0, 1.0 - abs(m - 1.0));
@ -670,7 +730,11 @@ const vertexShader = `
finalPos.z += sin(finalPos.x * 1.5 + finalPos.y * 1.5 + uTime * 1.6) * 0.06 * idleWobble;
vec4 mvPosition = modelViewMatrix * vec4(finalPos, 1.0);
gl_PointSize = (13.0 * uPixelRatio * uPointScale) * (1.0 / -mvPosition.z);
float pointSize = (13.0 * uPixelRatio * uPointScale) * (1.0 / -mvPosition.z);
if (vFlowerGlint > 0.0) {
pointSize *= 1.0 + vFlowerGlint * 0.75;
}
gl_PointSize = pointSize;
gl_Position = projectionMatrix * mvPosition;
vColor = finalCol;
vAlpha = 1.0;
@ -680,9 +744,11 @@ const vertexShader = `
const fragmentShader = `
uniform float uParticleAlpha;
uniform float uColorBoost;
uniform float uTime;
varying vec3 vColor;
varying float vAlpha;
varying float vFlowerGlint;
void main() {
vec2 center = vec2(0.5, 0.5);
@ -690,6 +756,17 @@ const fragmentShader = `
if (dist > 0.5) discard;
float alpha = smoothstep(0.5, 0.08, dist);
vec3 color = clamp(vColor * uColorBoost, 0.0, 1.0);
if (vFlowerGlint > 0.0) {
vec2 uv = gl_PointCoord - center;
float ang = atan(uv.y, uv.x);
float star = pow(abs(sin(ang * 4.0 + uTime * 9.0)), 3.0);
float core = 1.0 - smoothstep(0.0, 0.22, dist);
float glint = star * core * vFlowerGlint;
color += vec3(1.0, 0.98, 0.9) * glint * 1.4;
alpha = min(1.0, alpha * (1.0 + vFlowerGlint * 0.45));
}
gl_FragColor = vec4(color, alpha * uParticleAlpha * vAlpha);
}
`;

View file

@ -11,7 +11,7 @@ function readConfig() {
return {
brand: "Open Free Books",
homeUrl: "/",
browseUrl: "/browse/",
browseUrl: "/catalog/",
aboutUrl: "/about/",
githubUrl: "https://github.com/openfreebooks/openfreebooks",
};
@ -77,4 +77,12 @@ if (quadraticRoot) {
});
}
const catalogRoot = document.getElementById("catalog-app");
if (catalogRoot) {
void import("./components/catalog-app").then(({ CatalogApp }) => {
catalogRoot.replaceChildren();
render(() => <CatalogApp />, catalogRoot);
});
}
mountFooterYear();

View file

@ -0,0 +1,449 @@
.catalog-page {
padding: 24px 0 64px;
min-height: calc(100vh - 120px);
}
.catalog-error {
padding: 48px 24px;
text-align: center;
color: var(--soft-ink);
}
.catalog-shell {
display: grid;
grid-template-columns: minmax(12rem, 18rem) minmax(0, 1fr);
gap: 0;
max-width: 1400px;
margin: 0 auto;
min-height: calc(100vh - 160px);
border: 1px solid var(--card-border);
border-radius: 16px;
overflow: hidden;
background: var(--card-bg);
box-shadow: 0 12px 40px var(--shadow-soft);
}
.catalog-sidebar {
display: flex;
flex-direction: column;
border-right: 1px solid var(--line);
background: var(--paper);
}
.catalog-sidebar__header {
padding: 24px 20px 16px;
border-bottom: 1px solid var(--line);
}
.catalog-sidebar__kicker {
margin: 0;
font-family: var(--mono);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
}
.catalog-sidebar__subtitle {
margin: 8px 0 0;
font-size: 0.85rem;
line-height: 1.45;
color: var(--soft-ink);
}
.catalog-sidebar__nav {
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 12px 24px;
overflow-y: auto;
}
.catalog-subject-btn {
width: 100%;
text-align: left;
padding: 12px 16px;
border: none;
border-radius: 12px;
font-family: var(--font-body);
font-size: 0.95rem;
font-weight: 500;
color: var(--soft-ink);
background: transparent;
cursor: pointer;
transition: background 200ms ease, color 200ms ease, box-shadow 200ms ease;
}
.catalog-subject-btn:hover,
.catalog-subject-btn:focus-visible {
background: var(--card-bg);
color: var(--ink);
}
.catalog-subject-btn.is-active {
background: var(--accent);
color: #fff;
box-shadow: 0 4px 14px rgba(154, 107, 46, 0.35);
}
[data-theme="dark"] .catalog-subject-btn.is-active {
box-shadow: 0 4px 14px rgba(196, 146, 74, 0.25);
}
.catalog-main {
display: flex;
flex-direction: column;
min-height: 0;
background: var(--card-bg);
}
.catalog-header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
padding: 28px 32px 24px;
border-bottom: 1px solid var(--line);
background: var(--card-bg);
}
.catalog-header__title {
margin: 0;
font-family: var(--font-heading);
font-size: clamp(1.75rem, 3vw, 2.25rem);
font-weight: 400;
color: var(--ink);
line-height: 1.15;
}
.catalog-header__stats {
margin: 8px 0 0;
font-size: 0.9rem;
color: var(--muted);
}
.catalog-filters-wrap {
display: flex;
flex-direction: column;
gap: 8px;
}
.catalog-filters__label {
margin: 0;
font-family: var(--mono);
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.catalog-filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.catalog-filter-btn {
padding: 6px 16px;
border: 1px solid var(--line);
border-radius: 999px;
font-family: var(--font-body);
font-size: 0.82rem;
font-weight: 600;
color: var(--soft-ink);
background: var(--paper);
cursor: pointer;
transition: background 200ms ease, color 200ms ease, border-color 200ms ease;
}
.catalog-filter-btn:hover,
.catalog-filter-btn:focus-visible {
border-color: var(--accent);
color: var(--ink);
}
.catalog-filter-btn.is-active {
background: var(--ink);
border-color: var(--ink);
color: var(--paper);
}
.catalog-content {
flex: 1;
overflow-y: auto;
padding: 32px;
}
.catalog-strand {
margin-bottom: 48px;
animation: catalog-fade-in 0.4s ease-out forwards;
}
.catalog-strand:last-child {
margin-bottom: 0;
}
@keyframes catalog-fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.catalog-strand__title {
display: flex;
align-items: center;
gap: 12px;
margin: 0 0 24px;
font-family: var(--font-heading);
font-size: 1.35rem;
color: var(--ink);
}
.catalog-strand__marker {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid var(--line);
border-radius: 8px;
font-size: 0.75rem;
color: var(--muted);
background: var(--paper);
}
.catalog-tree {
list-style: none;
margin: 0;
padding: 0 0 0 24px;
position: relative;
}
.catalog-tree::before {
content: "";
position: absolute;
top: 8px;
bottom: 8px;
left: 11px;
width: 2px;
background: var(--line);
}
.catalog-tree__item {
position: relative;
margin-bottom: 16px;
}
.catalog-tree__item:last-child {
margin-bottom: 0;
}
.catalog-tree__item::before {
content: "";
position: absolute;
left: -19px;
top: 50%;
transform: translateY(-50%);
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--line);
box-shadow: 0 0 0 4px var(--card-bg);
transition: background 200ms ease;
}
.catalog-tree__item:hover::before,
.catalog-tree__item:focus-within::before {
background: var(--accent);
}
.catalog-chapter-card {
display: flex;
flex-direction: column;
gap: 12px;
margin-left: 8px;
padding: 16px 20px;
border: 1px solid var(--card-border);
border-radius: 12px;
background: var(--paper);
text-decoration: none;
color: var(--ink);
transition: border-color 200ms ease, box-shadow 200ms ease;
}
@media (min-width: 640px) {
.catalog-chapter-card {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
a.catalog-chapter-card:hover,
a.catalog-chapter-card:focus-visible {
border-color: var(--accent);
box-shadow: 0 6px 20px var(--shadow-soft);
}
.catalog-chapter-card--planned {
opacity: 0.85;
cursor: default;
}
.catalog-chapter-card__main {
display: flex;
align-items: center;
gap: 16px;
}
.catalog-chapter-card__number {
flex-shrink: 0;
width: 2rem;
font-family: var(--mono);
font-size: 0.8rem;
font-weight: 700;
color: var(--muted);
text-align: center;
}
a.catalog-chapter-card:hover .catalog-chapter-card__number,
a.catalog-chapter-card:focus-visible .catalog-chapter-card__number {
color: var(--accent);
}
.catalog-chapter-card__title {
font-family: var(--font-heading);
font-size: 1.1rem;
}
.catalog-chapter-card__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding-left: 48px;
}
@media (min-width: 640px) {
.catalog-chapter-card__meta {
padding-left: 0;
}
}
.catalog-badge {
padding: 4px 8px;
border: 1px solid transparent;
border-radius: 6px;
font-family: var(--mono);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.catalog-badge--dse {
color: #1d4ed8;
background: rgba(59, 130, 246, 0.12);
border-color: rgba(59, 130, 246, 0.25);
}
.catalog-badge--ib {
color: #6d28d9;
background: rgba(139, 92, 246, 0.12);
border-color: rgba(139, 92, 246, 0.25);
}
.catalog-badge--a-level {
color: #047857;
background: rgba(16, 185, 129, 0.12);
border-color: rgba(16, 185, 129, 0.25);
}
.catalog-badge--ap {
color: #b45309;
background: rgba(245, 158, 11, 0.12);
border-color: rgba(245, 158, 11, 0.25);
}
.catalog-badge--igcse {
color: #be123c;
background: rgba(244, 63, 94, 0.12);
border-color: rgba(244, 63, 94, 0.25);
}
.catalog-badge--default {
color: var(--soft-ink);
background: var(--paper);
border-color: var(--line);
}
.catalog-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 280px;
padding: 48px 24px;
text-align: center;
}
.catalog-empty__title {
margin: 0;
font-family: var(--font-heading);
font-size: 1.5rem;
color: var(--ink);
}
.catalog-empty__text {
margin: 12px 0 0;
max-width: 28rem;
font-size: 0.95rem;
line-height: 1.6;
color: var(--soft-ink);
}
@media (max-width: 768px) {
.catalog-shell {
grid-template-columns: 1fr;
min-height: auto;
}
.catalog-sidebar {
border-right: none;
border-bottom: 1px solid var(--line);
}
.catalog-sidebar__nav {
flex-direction: row;
flex-wrap: wrap;
padding: 12px;
}
.catalog-subject-btn {
width: auto;
flex: 1 1 auto;
min-width: calc(50% - 4px);
}
.catalog-header {
padding: 20px 20px 16px;
}
.catalog-content {
padding: 24px 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.catalog-strand {
animation: none;
}
}

View file

@ -8,4 +8,5 @@
@import "credits";
@import "about-manifest";
@import "book";
@import "catalog";
@import "math-widget";

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}{{ page.title }} | {{ config.extra.site_title }}{% endblock title %}
{% block meta_description %}
<meta name="description" content="Browse Open Free Books chapters by subject and curriculum." />
{% endblock meta_description %}
{% block content %}
{% set catalog = load_data(path="data/catalog.json", format="json") %}
{% set math = load_data(path="data/math-dse-curriculum.json", format="json") %}
<script id="catalog-data" type="application/json">
{
"title": {{ catalog.title | json_encode() | safe }},
"subtitle": {{ catalog.subtitle | json_encode() | safe }},
"curriculums": {{ catalog.curriculums | json_encode() | safe }},
"subjects": [
{% for subject in catalog.subjects %}
{
"id": {{ subject.id | json_encode() | safe }},
"name": {{ subject.name | json_encode() | safe }},
"strands": {% if subject.id == "math" %}{{ math.strands | json_encode() | safe }}{% else %}[]{% endif %}
}{% if not loop.last %},{% endif %}
{% endfor %}
]
}
</script>
<noscript>
<div class="catalog-page">
<div class="container">
<h1 class="section-title">{{ catalog.title }}</h1>
<p>{{ catalog.subtitle }}</p>
<p>
JavaScript is required for the interactive catalog. You can read
<a href="{{ get_url(path='/math/') | safe }}">Hong Kong Mathematics</a> directly.
</p>
</div>
</div>
</noscript>
<div id="catalog-app" class="catalog-page"></div>
{% endblock content %}

View file

@ -19,6 +19,6 @@ theme = "catppuccin-mocha"
[extra]
site_title = "Open Free Books"
github_url = "https://github.com/hananoshikayomaru/openfreebooks"
browse_url = "/browse/"
browse_url = "/catalog/"
about_url = "/about/"
credits_url = "/credits/"