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("