From 992275cc295e6db77eb53383e0c335daafea706d Mon Sep 17 00:00:00 2001 From: Hananoshika Yomaru Date: Tue, 19 May 2026 03:40:53 -0700 Subject: [PATCH] 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. --- content/catalog.md | 4 + data/catalog.json | 11 + data/catalog.types.ts | 25 + data/math-dse-curriculum.json | 36 +- frontend/src/components/catalog-app.tsx | 235 + frontend/src/lib/particle-background.ts | 117 +- frontend/src/main.tsx | 10 +- themes/openfreebooks/sass/_catalog.scss | 449 ++ themes/openfreebooks/sass/main.scss | 1 + themes/openfreebooks/static/js/bundle.js | 2 +- .../static/js/catalog-app-BEjaT8IN.js | 1 + .../static/js/particle-background-BHLcbCB2.js | 4235 ++++++++++++++++ .../static/js/particle-background-BQF-S_Ld.js | 4235 ++++++++++++++++ .../static/js/particle-background-BWT17mYm.js | 4235 ++++++++++++++++ .../static/js/particle-background-BfvCm27S.js | 4235 ++++++++++++++++ .../static/js/particle-background-D-ZrRWEh.js | 4235 ++++++++++++++++ .../static/js/particle-background-D6AacBl-.js | 4265 +++++++++++++++++ .../static/js/particle-background-UWX4e30w.js | 4235 ++++++++++++++++ .../static/js/particle-background-x_Kpgyrz.js | 4235 ++++++++++++++++ themes/openfreebooks/templates/catalog.html | 42 + zola.toml | 2 +- 21 files changed, 34810 insertions(+), 35 deletions(-) create mode 100644 content/catalog.md create mode 100644 data/catalog.json create mode 100644 data/catalog.types.ts create mode 100644 frontend/src/components/catalog-app.tsx create mode 100644 themes/openfreebooks/sass/_catalog.scss create mode 100644 themes/openfreebooks/static/js/catalog-app-BEjaT8IN.js create mode 100644 themes/openfreebooks/static/js/particle-background-BHLcbCB2.js create mode 100644 themes/openfreebooks/static/js/particle-background-BQF-S_Ld.js create mode 100644 themes/openfreebooks/static/js/particle-background-BWT17mYm.js create mode 100644 themes/openfreebooks/static/js/particle-background-BfvCm27S.js create mode 100644 themes/openfreebooks/static/js/particle-background-D-ZrRWEh.js create mode 100644 themes/openfreebooks/static/js/particle-background-D6AacBl-.js create mode 100644 themes/openfreebooks/static/js/particle-background-UWX4e30w.js create mode 100644 themes/openfreebooks/static/js/particle-background-x_Kpgyrz.js create mode 100644 themes/openfreebooks/templates/catalog.html diff --git a/content/catalog.md b/content/catalog.md new file mode 100644 index 0000000..8f42fdb --- /dev/null +++ b/content/catalog.md @@ -0,0 +1,4 @@ ++++ +title = "Catalog" +template = "catalog.html" ++++ diff --git a/data/catalog.json b/data/catalog.json new file mode 100644 index 0000000..82f68cf --- /dev/null +++ b/data/catalog.json @@ -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" } + ] +} diff --git a/data/catalog.types.ts b/data/catalog.types.ts new file mode 100644 index 0000000..fbeb73d --- /dev/null +++ b/data/catalog.types.ts @@ -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[]; +}; diff --git a/data/math-dse-curriculum.json b/data/math-dse-curriculum.json index 356f470..13eb276 100644 --- a/data/math-dse-curriculum.json +++ b/data/math-dse-curriculum.json @@ -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"] } ] } diff --git a/frontend/src/components/catalog-app.tsx b/frontend/src/components/catalog-app.tsx new file mode 100644 index 0000000..9f3ea27 --- /dev/null +++ b/frontend/src/components/catalog-app.tsx @@ -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 = { + 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): boolean { + if (activeFilters.size === 0) return true; + return chapter.curriculums.some((c) => activeFilters.has(c)); +} + +function countVisibleChapters(subject: CatalogSubject, activeFilters: Set): 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

Catalog data could not be loaded.

; + } + + const [currentSubjectId, setCurrentSubjectId] = createSignal(initialSubjectId(data.subjects)); + const [activeFilters, setActiveFilters] = createSignal>(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()); + 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 ( +
+ + +
+
+
+

{currentSubject().name}

+

{statsLabel()}

+
+
+

Filter by curriculum

+
+ + + {(curriculum) => ( + + )} + +
+
+
+ +
+ 0} + fallback={ +
+

+ {hasChapterTree() ? "No chapters found" : "Coming soon"} +

+

+ {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."} +

+
+ } + > + + {(strand: CatalogStrand) => ( +
+

+ + {strand.title} +

+
    + + {(chapter, index) => ( +
  1. + +
  2. + )} +
    +
+
+ )} +
+
+
+
+
+ ); +} + +function ChapterCard(props: { chapter: CatalogChapter; index: number }) { + const number = () => String(props.index + 1).padStart(2, "0"); + + const inner = ( + <> +
+ {number()} + {props.chapter.title} +
+
+ + {(curriculum) => ( + {curriculum} + )} + + + Coming soon + +
+ + ); + + return ( + {inner}} + > + + {inner} + + + ); +} diff --git a/frontend/src/lib/particle-background.ts b/frontend/src/lib/particle-background.ts index 54fe30b..0210ab1 100644 --- a/frontend/src/lib/particle-background.ts +++ b/frontend/src/lib/particle-background.ts @@ -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(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(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); } `; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index ae27a01..cc56f44 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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(() => , catalogRoot); + }); +} + mountFooterYear(); diff --git a/themes/openfreebooks/sass/_catalog.scss b/themes/openfreebooks/sass/_catalog.scss new file mode 100644 index 0000000..090a3f1 --- /dev/null +++ b/themes/openfreebooks/sass/_catalog.scss @@ -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; + } +} diff --git a/themes/openfreebooks/sass/main.scss b/themes/openfreebooks/sass/main.scss index c62b932..20f2955 100644 --- a/themes/openfreebooks/sass/main.scss +++ b/themes/openfreebooks/sass/main.scss @@ -8,4 +8,5 @@ @import "credits"; @import "about-manifest"; @import "book"; +@import "catalog"; @import "math-widget"; diff --git a/themes/openfreebooks/static/js/bundle.js b/themes/openfreebooks/static/js/bundle.js index d4ec4f3..72e359d 100644 --- a/themes/openfreebooks/static/js/bundle.js +++ b/themes/openfreebooks/static/js/bundle.js @@ -1,2 +1,2 @@ const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["js/quadratic-explorer-BEDvXl1S.js","js/katex-Dc8nsIP1.js","js/render-math-H0D_R-AC.js","js/katex.css"])))=>i.map(i=>d[i]); -const Ae="modulepreload",ke=function(e){return"/"+e},ee={},Q=function(t,n,s){let r=Promise.resolve();if(n&&n.length>0){let l=function(u){return Promise.all(u.map(f=>Promise.resolve(f).then(a=>({status:"fulfilled",value:a}),a=>({status:"rejected",reason:a}))))};document.getElementsByTagName("link");const o=document.querySelector("meta[property=csp-nonce]"),c=(o==null?void 0:o.nonce)||(o==null?void 0:o.getAttribute("nonce"));r=l(n.map(u=>{if(u=ke(u),u in ee)return;ee[u]=!0;const f=u.endsWith(".css"),a=f?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${u}"]${a}`))return;const m=document.createElement("link");if(m.rel=f?"stylesheet":Ae,f||(m.as="script"),m.crossOrigin="",m.href=u,c&&m.setAttribute("nonce",c),document.head.appendChild(m),f)return new Promise((b,v)=>{m.addEventListener("load",b),m.addEventListener("error",()=>v(new Error(`Unable to preload CSS for ${u}`)))})}))}function i(l){const o=new Event("vite:preloadError",{cancelable:!0});if(o.payload=l,window.dispatchEvent(o),!o.defaultPrevented)throw l}return r.then(l=>{for(const o of l||[])o.status==="rejected"&&i(o.reason);return t().catch(i)})};const Ce=(e,t)=>e===t,$e=Symbol("solid-track"),j={equals:Ce};let fe=me;const L=1,V=2,de={owned:null,cleanups:null,context:null,owner:null};var g=null;let J=null,_e=null,h=null,w=null,$=null,Y=0;function N(e,t){const n=h,s=g,r=e.length===0,i=t===void 0?s:t,l=r?de:{owned:null,cleanups:null,context:i?i.context:null,owner:i},o=r?e:()=>e(()=>x(()=>P(l)));g=l,h=null;try{return U(o,!0)}finally{h=n,g=s}}function G(e,t){t=t?Object.assign({},j,t):j;const n={value:e,observers:null,observerSlots:null,comparator:t.equals||void 0},s=r=>(typeof r=="function"&&(r=r(n.value)),ge(n,r));return[he.bind(n),s]}function B(e,t,n){const s=z(e,t,!1,L);q(s)}function Te(e,t,n){fe=Be;const s=z(e,t,!1,L);s.user=!0,$?$.push(s):q(s)}function R(e,t,n){n=n?Object.assign({},j,n):j;const s=z(e,t,!0,0);return s.observers=null,s.observerSlots=null,s.comparator=n.equals||void 0,q(s),he.bind(s)}function x(e){if(h===null)return e();const t=h;h=null;try{return e()}finally{h=t}}function W(e){Te(()=>x(e))}function Z(e){return g===null||(g.cleanups===null?g.cleanups=[e]:g.cleanups.push(e)),e}function he(){if(this.sources&&this.state)if(this.state===L)q(this);else{const e=w;w=null,U(()=>F(this),!1),w=e}if(h){const e=this.observers;if(!e||e[e.length-1]!==h){const t=e?e.length:0;h.sources?(h.sources.push(this),h.sourceSlots.push(t)):(h.sources=[this],h.sourceSlots=[t]),e?(e.push(h),this.observerSlots.push(h.sources.length-1)):(this.observers=[h],this.observerSlots=[h.sources.length-1])}}return this.value}function ge(e,t,n){let s=e.value;return(!e.comparator||!e.comparator(s,t))&&(e.value=t,e.observers&&e.observers.length&&U(()=>{for(let r=0;r1e6)throw w=[],new Error},!1)),t}function q(e){if(!e.fn)return;P(e);const t=Y;xe(e,e.value,t)}function xe(e,t,n){let s;const r=g,i=h;h=g=e;try{s=e.fn(t)}catch(l){return e.pure&&(e.state=L,e.owned&&e.owned.forEach(P),e.owned=null),e.updatedAt=n+1,pe(l)}finally{h=i,g=r}(!e.updatedAt||e.updatedAt<=n)&&(e.updatedAt!=null&&"observers"in e?ge(e,s):e.value=s,e.updatedAt=n)}function z(e,t,n,s=L,r){const i={fn:e,state:s,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:g,context:g?g.context:null,pure:n};return g===null||g!==de&&(g.owned?g.owned.push(i):g.owned=[i]),i}function H(e){if(e.state===0)return;if(e.state===V)return F(e);if(e.suspense&&x(e.suspense.inFallback))return e.suspense.effects.push(e);const t=[e];for(;(e=e.owner)&&(!e.updatedAt||e.updatedAt=0;n--)if(e=t[n],e.state===L)q(e);else if(e.state===V){const s=w;w=null,U(()=>F(e,t[0]),!1),w=s}}function U(e,t){if(w)return e();let n=!1;t||(w=[]),$?n=!0:$=[],Y++;try{const s=e();return Le(n),s}catch(s){n||($=null),w=null,pe(s)}}function Le(e){if(w&&(me(w),w=null),e)return;const t=$;$=null,t.length&&U(()=>fe(t),!1)}function me(e){for(let t=0;t=0;t--)P(e.tOwned[t]);delete e.tOwned}if(e.owned){for(t=e.owned.length-1;t>=0;t--)P(e.owned[t]);e.owned=null}if(e.cleanups){for(t=e.cleanups.length-1;t>=0;t--)e.cleanups[t]();e.cleanups=null}e.state=0}function Ie(e){return e instanceof Error?e:new Error(typeof e=="string"?e:"Unknown error",{cause:e})}function pe(e,t=g){throw Ie(e)}const Oe=Symbol("fallback");function te(e){for(let t=0;t1?[]:null;return Z(()=>te(i)),()=>{let c=e()||[],u=c.length,f,a;return c[$e],x(()=>{let b,v,k,d,p,y,E,S,C;if(u===0)l!==0&&(te(i),i=[],s=[],r=[],l=0,o&&(o=[])),n.fallback&&(s=[Oe],r[0]=N(Se=>(i[0]=Se,n.fallback())),l=1);else if(l===0){for(r=new Array(u),a=0;a=y&&S>=y&&s[E]===c[S];E--,S--)k[S]=r[E],d[S]=i[E],o&&(p[S]=o[E]);for(b=new Map,v=new Array(S+1),a=S;a>=y;a--)C=c[a],f=b.get(C),v[a]=f===void 0?-1:f,b.set(C,a);for(f=y;f<=E;f++)C=s[f],a=b.get(C),a!==void 0&&a!==-1?(k[a]=r[f],d[a]=i[f],o&&(p[a]=o[f]),a=v[a],b.set(C,a)):i[f]();for(a=y;ae(t||{}))}const Pe=e=>`Stale read from <${e}>.`;function ne(e){const t="fallback"in e&&{fallback:()=>e.fallback};return R(De(()=>e.each,e.children,t||void 0))}function qe(e){const t=e.keyed,n=R(()=>e.when,void 0,void 0),s=t?n:R(n,void 0,{equals:(r,i)=>!r==!i});return R(()=>{const r=s();if(r){const i=e.children;return typeof i=="function"&&i.length>0?x(()=>i(t?r:()=>{if(!x(s))throw Pe("Show");return n()})):i}return e.fallback},void 0,void 0)}function Ue(e,t,n){let s=n.length,r=t.length,i=s,l=0,o=0,c=t[r-1].nextSibling,u=null;for(;lf-o){const v=t[l];for(;o{r=i,t===document?e():A(t,e(),t.firstChild?null:void 0,n)},s.owner),()=>{r(),t.textContent=""}}function I(e,t,n,s){let r;const i=()=>{const o=document.createElement("template");return o.innerHTML=e,o.content.firstChild},l=()=>(r||(r=i())).cloneNode(!0);return l.cloneNode=l,l}function we(e,t=window.document){const n=t[se]||(t[se]=new Set);for(let s=0,r=e.length;se(t,n))}function A(e,t,n,s){if(n!==void 0&&!s&&(s=[]),typeof t!="function")return K(e,t,s,n);B(r=>K(e,t(),r,n),s)}function re(e,t,n){const s=t.trim().split(/\s+/);for(let r=0,i=s.length;rObject.defineProperty(e,"target",{configurable:!0,value:c}),l=()=>{const c=t[n];if(c&&!t.disabled){const u=t[`${n}Data`];if(u!==void 0?c.call(t,u,e):c.call(t,e),e.cancelBubble)return}return t.host&&typeof t.host!="string"&&!t.host._$host&&t.contains(e.target)&&i(t.host),!0},o=()=>{for(;l()&&(t=t._$host||t.parentNode||t.host););};if(Object.defineProperty(e,"currentTarget",{configurable:!0,get(){return t||document}}),e.composedPath){const c=e.composedPath();i(c[0]);for(let u=0;u{let o=t();for(;typeof o=="function";)o=o();n=K(e,o,n,s)}),()=>n;if(Array.isArray(t)){const o=[],c=n&&Array.isArray(n);if(X(o,t,n,r))return B(()=>n=K(e,o,n,s,!0)),()=>n;if(o.length===0){if(n=O(e,n,s),l)return n}else c?n.length===0?oe(e,o,s):Ue(e,n,o):(n&&O(e),oe(e,o));n=o}else if(t.nodeType){if(Array.isArray(n)){if(l)return n=O(e,n,s,t);O(e,n,null,t)}else n==null||n===""||!e.firstChild?e.appendChild(t):e.replaceChild(t,e.firstChild);n=t}}return n}function X(e,t,n,s){let r=!1;for(let i=0,l=t.length;i=0;l--){const o=t[l];if(r!==o){const c=o.parentNode===e;!i&&!l?c?e.replaceChild(r,o):e.insertBefore(r,n):c&&o.remove()}else i=!0}}else e.insertBefore(r,n);return[r]}const ve="openfreebooks-theme";function Re(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function je(){const e=localStorage.getItem(ve);return e==="light"||e==="dark"?e:null}function ye(){return je()??Re()}function Ee(e){document.documentElement.setAttribute("data-theme",e)}function Ve(e){localStorage.setItem(ve,e),Ee(e)}function Ge(){Ee(ye())}var He=I("