mirror of
https://github.com/HananoshikaYomaru/openfreebooks.git
synced 2026-11-13 21:15:54 +00:00
Update dependencies in package.json and bun.lock, enhance README with curriculum contribution guidelines, and improve catalog structure with graph data for mathematics. Add new styles for chapter headers and catalog view toggles in SASS, and implement copy page button functionality in frontend components.
This commit is contained in:
parent
778b2093f8
commit
8c986c1ed7
64 changed files with 8435 additions and 106 deletions
|
|
@ -1,5 +1,18 @@
|
|||
import { createMemo, createSignal, For, Show } from "solid-js";
|
||||
import type { CatalogChapter, CatalogData, CatalogStrand, CatalogSubject } from "../../../data/catalog.types";
|
||||
import { createMemo, createSignal, For, lazy, onCleanup, onMount, Show, Suspense } from "solid-js";
|
||||
import { subjectToCanvas } from "../lib/catalog-to-canvas";
|
||||
import { watchSiteTheme } from "../lib/catalog-canvas-theme";
|
||||
import { CatalogChapterCard } from "./catalog-chapter-card";
|
||||
import type {
|
||||
CatalogChapter,
|
||||
CatalogData,
|
||||
CatalogStrand,
|
||||
CatalogSubject,
|
||||
CatalogViewMode,
|
||||
} from "../../../data/catalog.types";
|
||||
|
||||
const CatalogCanvasView = lazy(() =>
|
||||
import("./catalog-canvas-view").then((m) => ({ default: m.CatalogCanvasView }))
|
||||
);
|
||||
|
||||
function readCatalogData(): CatalogData | null {
|
||||
const el = document.getElementById("catalog-data");
|
||||
|
|
@ -16,15 +29,9 @@ function initialSubjectId(subjects: CatalogSubject[]): string {
|
|||
return subjects[0]?.id ?? "math";
|
||||
}
|
||||
|
||||
function curriculumBadgeClass(curriculum: string): string {
|
||||
const map: Record<string, string> = {
|
||||
DSE: "catalog-badge--dse",
|
||||
IB: "catalog-badge--ib",
|
||||
"A-Level": "catalog-badge--a-level",
|
||||
AP: "catalog-badge--ap",
|
||||
IGCSE: "catalog-badge--igcse",
|
||||
};
|
||||
return map[curriculum] ?? "catalog-badge--default";
|
||||
function initialViewMode(): CatalogViewMode {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get("view") === "tree" ? "tree" : "linear";
|
||||
}
|
||||
|
||||
function chapterMatchesFilters(chapter: CatalogChapter, activeFilters: Set<string>): boolean {
|
||||
|
|
@ -38,9 +45,14 @@ function countVisibleChapters(subject: CatalogSubject, activeFilters: Set<string
|
|||
}, 0);
|
||||
}
|
||||
|
||||
function syncSubjectUrl(subjectId: string) {
|
||||
function syncUrl(subjectId: string, viewMode: CatalogViewMode) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("subject", subjectId);
|
||||
if (viewMode === "tree") {
|
||||
url.searchParams.set("view", "tree");
|
||||
} else {
|
||||
url.searchParams.delete("view");
|
||||
}
|
||||
window.history.replaceState(null, "", url);
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +64,17 @@ export function CatalogApp() {
|
|||
|
||||
const [currentSubjectId, setCurrentSubjectId] = createSignal(initialSubjectId(data.subjects));
|
||||
const [activeFilters, setActiveFilters] = createSignal<Set<string>>(new Set());
|
||||
const [viewMode, setViewMode] = createSignal<CatalogViewMode>(initialViewMode());
|
||||
const [canvasThemeTick, setCanvasThemeTick] = createSignal(0);
|
||||
const [mapLayoutReady, setMapLayoutReady] = createSignal(!document.fonts);
|
||||
|
||||
onMount(() => {
|
||||
const stopThemeWatch = watchSiteTheme(() => setCanvasThemeTick((tick) => tick + 1));
|
||||
if (document.fonts) {
|
||||
void document.fonts.ready.then(() => setMapLayoutReady(true));
|
||||
}
|
||||
onCleanup(stopThemeWatch);
|
||||
});
|
||||
|
||||
const currentSubject = createMemo(
|
||||
() => data.subjects.find((s) => s.id === currentSubjectId()) ?? data.subjects[0]
|
||||
|
|
@ -61,6 +84,28 @@ export function CatalogApp() {
|
|||
|
||||
const hasChapterTree = createMemo(() => currentSubject().strands.length > 0);
|
||||
|
||||
const chapterFilter = createMemo(
|
||||
() => (chapter: CatalogChapter) => chapterMatchesFilters(chapter, activeFilters())
|
||||
);
|
||||
|
||||
const canvasData = createMemo(() => {
|
||||
canvasThemeTick();
|
||||
if (!mapLayoutReady()) return undefined;
|
||||
return subjectToCanvas(currentSubject(), chapterFilter());
|
||||
});
|
||||
|
||||
const visibleChapters = createMemo(() => {
|
||||
const map: Record<string, CatalogChapter> = {};
|
||||
for (const strand of currentSubject().strands) {
|
||||
for (const chapter of strand.chapters) {
|
||||
if (chapterFilter()(chapter)) {
|
||||
map[chapter.slug] = chapter;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const statsLabel = createMemo(() => {
|
||||
const count = visibleCount();
|
||||
const filters = activeFilters();
|
||||
|
|
@ -71,7 +116,12 @@ export function CatalogApp() {
|
|||
|
||||
const selectSubject = (subjectId: string) => {
|
||||
setCurrentSubjectId(subjectId);
|
||||
syncSubjectUrl(subjectId);
|
||||
syncUrl(subjectId, viewMode());
|
||||
};
|
||||
|
||||
const setCatalogView = (mode: CatalogViewMode) => {
|
||||
setViewMode(mode);
|
||||
syncUrl(currentSubjectId(), mode);
|
||||
};
|
||||
|
||||
const toggleFilter = (curriculum: string) => {
|
||||
|
|
@ -129,34 +179,59 @@ export function CatalogApp() {
|
|||
<h1 class="catalog-header__title">{currentSubject().name}</h1>
|
||||
<p class="catalog-header__stats">{statsLabel()}</p>
|
||||
</div>
|
||||
<div class="catalog-filters-wrap">
|
||||
<p class="catalog-filters__label">Filter by curriculum</p>
|
||||
<div class="catalog-filters" role="group" aria-label="Curriculum filters">
|
||||
<div class="catalog-header__controls">
|
||||
<div class="catalog-view-toggle" role="tablist" aria-label="Chapter layout">
|
||||
<button
|
||||
type="button"
|
||||
class="catalog-filter-btn"
|
||||
classList={{ "is-active": activeFilters().size === 0 }}
|
||||
onClick={() => toggleFilter("ALL")}
|
||||
class="catalog-view-toggle__btn"
|
||||
role="tab"
|
||||
aria-selected={viewMode() === "linear" ? "true" : "false"}
|
||||
onClick={() => setCatalogView("linear")}
|
||||
>
|
||||
All
|
||||
List
|
||||
</button>
|
||||
<For each={data.curriculums}>
|
||||
{(curriculum) => (
|
||||
<button
|
||||
type="button"
|
||||
class="catalog-filter-btn"
|
||||
classList={{ "is-active": activeFilters().has(curriculum) }}
|
||||
onClick={() => toggleFilter(curriculum)}
|
||||
>
|
||||
{curriculum}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<button
|
||||
type="button"
|
||||
class="catalog-view-toggle__btn"
|
||||
role="tab"
|
||||
aria-selected={viewMode() === "tree" ? "true" : "false"}
|
||||
onClick={() => setCatalogView("tree")}
|
||||
>
|
||||
Map
|
||||
</button>
|
||||
</div>
|
||||
<div class="catalog-filters-wrap">
|
||||
<p class="catalog-filters__label">Filter by curriculum</p>
|
||||
<div class="catalog-filters" role="group" aria-label="Curriculum filters">
|
||||
<button
|
||||
type="button"
|
||||
class="catalog-filter-btn"
|
||||
classList={{ "is-active": activeFilters().size === 0 }}
|
||||
onClick={() => toggleFilter("ALL")}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<For each={data.curriculums}>
|
||||
{(curriculum) => (
|
||||
<button
|
||||
type="button"
|
||||
class="catalog-filter-btn"
|
||||
classList={{ "is-active": activeFilters().has(curriculum) }}
|
||||
onClick={() => toggleFilter(curriculum)}
|
||||
>
|
||||
{curriculum}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="catalog-content">
|
||||
<div
|
||||
class="catalog-content"
|
||||
classList={{ "catalog-content--tree": viewMode() === "tree" }}
|
||||
>
|
||||
<Show
|
||||
when={hasChapterTree() && visibleCount() > 0}
|
||||
fallback={
|
||||
|
|
@ -172,64 +247,55 @@ export function CatalogApp() {
|
|||
</div>
|
||||
}
|
||||
>
|
||||
<For each={filteredStrands()}>
|
||||
{(strand: CatalogStrand) => (
|
||||
<section class="catalog-strand" aria-labelledby={`strand-${strand.id}`}>
|
||||
<h2 id={`strand-${strand.id}`} class="catalog-strand__title">
|
||||
<span class="catalog-strand__marker" aria-hidden="true">
|
||||
✦
|
||||
</span>
|
||||
{strand.title}
|
||||
</h2>
|
||||
<ol class="catalog-tree">
|
||||
<For each={strand.chapters}>
|
||||
{(chapter, index) => (
|
||||
<li class="catalog-tree__item">
|
||||
<ChapterCard chapter={chapter} index={index()} />
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
</For>
|
||||
<Show
|
||||
when={viewMode() === "tree"}
|
||||
fallback={
|
||||
<For each={filteredStrands()}>
|
||||
{(strand: CatalogStrand) => (
|
||||
<section class="catalog-strand" aria-labelledby={`strand-${strand.id}`}>
|
||||
<h2 id={`strand-${strand.id}`} class="catalog-strand__title">
|
||||
<span class="catalog-strand__marker" aria-hidden="true">
|
||||
✦
|
||||
</span>
|
||||
{strand.title}
|
||||
</h2>
|
||||
<ol class="catalog-tree">
|
||||
<For each={strand.chapters}>
|
||||
{(chapter, index) => (
|
||||
<li class="catalog-tree__item">
|
||||
<CatalogChapterCard
|
||||
chapter={chapter}
|
||||
index={index()}
|
||||
subjectId={currentSubjectId()}
|
||||
variant="list"
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
</For>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={canvasData()}
|
||||
fallback={<p class="catalog-canvas-loading">Loading chapter map…</p>}
|
||||
>
|
||||
{(canvas) => (
|
||||
<Suspense fallback={<p class="catalog-canvas-loading">Loading chapter map…</p>}>
|
||||
<CatalogCanvasView
|
||||
canvas={canvas()}
|
||||
subjectId={currentSubjectId()}
|
||||
chapters={visibleChapters()}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChapterCard(props: { chapter: CatalogChapter; index: number }) {
|
||||
const number = () => String(props.index + 1).padStart(2, "0");
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<div class="catalog-chapter-card__main">
|
||||
<span class="catalog-chapter-card__number">{number()}</span>
|
||||
<span class="catalog-chapter-card__title">{props.chapter.title}</span>
|
||||
</div>
|
||||
<div class="catalog-chapter-card__meta">
|
||||
<For each={props.chapter.curriculums}>
|
||||
{(curriculum) => (
|
||||
<span class={`catalog-badge ${curriculumBadgeClass(curriculum)}`}>{curriculum}</span>
|
||||
)}
|
||||
</For>
|
||||
<Show when={props.chapter.status === "planned"}>
|
||||
<span class="book-chapter-badge book-chapter-badge--planned">Coming soon</span>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={props.chapter.status === "live"}
|
||||
fallback={<div class="catalog-chapter-card catalog-chapter-card--planned">{inner}</div>}
|
||||
>
|
||||
<a class="catalog-chapter-card" href={`/math/${props.chapter.slug}/`}>
|
||||
{inner}
|
||||
</a>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
181
frontend/src/components/catalog-canvas-view.tsx
Normal file
181
frontend/src/components/catalog-canvas-view.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { createEffect, onCleanup, onMount } from "solid-js";
|
||||
import type {
|
||||
JSONCanvas,
|
||||
JSONCanvasTextNode,
|
||||
JSONCanvasViewerInterface,
|
||||
} from "json-canvas-viewer";
|
||||
import {
|
||||
JSONCanvasViewer,
|
||||
MistouchPreventer,
|
||||
Controls,
|
||||
Minimap,
|
||||
parser,
|
||||
} from "json-canvas-viewer";
|
||||
import type { CatalogChapter } from "../../../data/catalog.types";
|
||||
import { renderCatalogChapterCardElement } from "./catalog-chapter-card";
|
||||
import { chapterSlugFromNodeId, relayoutCanvasChapterHeights } from "../lib/catalog-to-canvas";
|
||||
import { measureRenderedMapChapterCards } from "../lib/catalog-chapter-card-layout";
|
||||
import {
|
||||
syncViewerTheme,
|
||||
VIEWER_THEME_COLORS,
|
||||
siteCanvasTheme,
|
||||
watchSiteTheme,
|
||||
} from "../lib/catalog-canvas-theme";
|
||||
|
||||
type CatalogCanvasViewProps = {
|
||||
canvas: JSONCanvas;
|
||||
subjectId: string;
|
||||
chapters: Record<string, CatalogChapter>;
|
||||
};
|
||||
|
||||
function serializeCanvas(canvas: JSONCanvas): string {
|
||||
return JSON.stringify(canvas);
|
||||
}
|
||||
|
||||
function waitForLayout(el: HTMLElement): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
if (el.clientWidth >= 1 && el.clientHeight >= 1) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(check);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
function bindPageScrollWheel(container: HTMLElement): () => void {
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (event.ctrlKey || event.metaKey) return;
|
||||
event.stopPropagation();
|
||||
};
|
||||
container.addEventListener("wheel", onWheel, { capture: true, passive: true });
|
||||
return () => container.removeEventListener("wheel", onWheel, { capture: true });
|
||||
}
|
||||
|
||||
function waitForFrames(count = 2): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
let remaining = count;
|
||||
const step = () => {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
});
|
||||
}
|
||||
|
||||
function buildNodeComponents(
|
||||
subjectId: string,
|
||||
chapters: Record<string, CatalogChapter>
|
||||
) {
|
||||
return {
|
||||
text: ({
|
||||
container,
|
||||
node,
|
||||
}: {
|
||||
container: HTMLDivElement;
|
||||
node: JSONCanvasTextNode;
|
||||
}) => {
|
||||
const slug = chapterSlugFromNodeId(node.id);
|
||||
if (!slug) return;
|
||||
const chapter = chapters[slug];
|
||||
if (!chapter) return;
|
||||
container.replaceChildren();
|
||||
container.appendChild(renderCatalogChapterCardElement(chapter, subjectId, "map"));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function CatalogCanvasView(props: CatalogCanvasViewProps) {
|
||||
let container!: HTMLDivElement;
|
||||
let viewer: JSONCanvasViewerInterface | undefined;
|
||||
let lastSerialized = "";
|
||||
let loadGeneration = 0;
|
||||
let unbindWheel: (() => void) | undefined;
|
||||
|
||||
const chaptersKey = (chapters: Record<string, CatalogChapter>) =>
|
||||
JSON.stringify(
|
||||
Object.entries(chapters).map(([slug, ch]) => [slug, ch.title, ch.description, ch.status])
|
||||
);
|
||||
|
||||
const loadCanvas = async (canvas: JSONCanvas, chapters: Record<string, CatalogChapter>) => {
|
||||
const cacheKey = `${serializeCanvas(canvas)}|${chaptersKey(chapters)}`;
|
||||
if (!viewer || cacheKey === lastSerialized) return;
|
||||
|
||||
const generation = ++loadGeneration;
|
||||
|
||||
await waitForLayout(container);
|
||||
if (generation !== loadGeneration || !viewer) return;
|
||||
|
||||
lastSerialized = cacheKey;
|
||||
viewer.load({ canvas });
|
||||
syncViewerTheme(viewer);
|
||||
|
||||
await waitForFrames();
|
||||
if (generation !== loadGeneration || !viewer) return;
|
||||
|
||||
const measured = measureRenderedMapChapterCards(container);
|
||||
const fitted = relayoutCanvasChapterHeights(canvas, measured);
|
||||
if (!fitted) return;
|
||||
|
||||
const fittedKey = `${serializeCanvas(fitted)}|${chaptersKey(chapters)}`;
|
||||
if (fittedKey === lastSerialized) return;
|
||||
|
||||
lastSerialized = fittedKey;
|
||||
viewer.load({ canvas: fitted });
|
||||
syncViewerTheme(viewer);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
let stopThemeWatch: (() => void) | undefined;
|
||||
|
||||
unbindWheel = bindPageScrollWheel(container);
|
||||
|
||||
viewer = new JSONCanvasViewer(
|
||||
{
|
||||
container,
|
||||
parser,
|
||||
theme: siteCanvasTheme(),
|
||||
colors: VIEWER_THEME_COLORS,
|
||||
loading: "none",
|
||||
preventMistouchAtStart: false,
|
||||
mistouchPreventerBannerText: "Click the map to pan and zoom",
|
||||
minimapCollapsed: true,
|
||||
nodeComponents: buildNodeComponents(props.subjectId, props.chapters),
|
||||
},
|
||||
[MistouchPreventer, Controls, Minimap]
|
||||
);
|
||||
|
||||
stopThemeWatch = watchSiteTheme(() => syncViewerTheme(viewer));
|
||||
|
||||
void loadCanvas(props.canvas, props.chapters);
|
||||
|
||||
onCleanup(() => {
|
||||
loadGeneration += 1;
|
||||
stopThemeWatch?.();
|
||||
unbindWheel?.();
|
||||
unbindWheel = undefined;
|
||||
viewer?.dispose();
|
||||
viewer = undefined;
|
||||
lastSerialized = "";
|
||||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!viewer) return;
|
||||
viewer.options.nodeComponents = buildNodeComponents(
|
||||
props.subjectId,
|
||||
props.chapters
|
||||
);
|
||||
void loadCanvas(props.canvas, props.chapters);
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={container} class="catalog-canvas-view" role="application" aria-label="Chapter map" />
|
||||
);
|
||||
}
|
||||
189
frontend/src/components/catalog-chapter-card.tsx
Normal file
189
frontend/src/components/catalog-chapter-card.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { For, Show } from "solid-js";
|
||||
import type { CatalogChapter } from "../../../data/catalog.types";
|
||||
import { curriculumBadgeClass } from "../lib/catalog-badge";
|
||||
|
||||
export type CatalogChapterCardProps = {
|
||||
chapter: CatalogChapter;
|
||||
subjectId: string;
|
||||
index?: number;
|
||||
variant?: "list" | "map";
|
||||
};
|
||||
|
||||
function chapterHref(subjectId: string, slug: string): string {
|
||||
return `/${subjectId}/${slug}/`;
|
||||
}
|
||||
|
||||
function appendMeta(parent: HTMLElement, chapter: CatalogChapter) {
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "catalog-chapter-card__meta";
|
||||
|
||||
for (const curriculum of chapter.curriculums) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = `catalog-badge ${curriculumBadgeClass(curriculum)}`;
|
||||
badge.textContent = curriculum;
|
||||
meta.appendChild(badge);
|
||||
}
|
||||
|
||||
if (chapter.status === "planned") {
|
||||
const soon = document.createElement("span");
|
||||
soon.className = "book-chapter-badge book-chapter-badge--planned";
|
||||
soon.textContent = "Coming soon";
|
||||
meta.appendChild(soon);
|
||||
}
|
||||
|
||||
parent.appendChild(meta);
|
||||
}
|
||||
|
||||
function appendMapTitle(
|
||||
body: HTMLElement,
|
||||
chapter: CatalogChapter,
|
||||
subjectId: string
|
||||
) {
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "catalog-chapter-card__title";
|
||||
|
||||
if (chapter.status === "live") {
|
||||
const link = document.createElement("a");
|
||||
link.className = "catalog-chapter-card__title-link";
|
||||
link.href = chapterHref(subjectId, chapter.slug);
|
||||
link.addEventListener("click", (event) => event.stopPropagation());
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.className = "catalog-chapter-card__title-text";
|
||||
text.textContent = chapter.title;
|
||||
|
||||
const arrow = document.createElement("span");
|
||||
arrow.className = "catalog-chapter-card__title-arrow";
|
||||
arrow.setAttribute("aria-hidden", "true");
|
||||
arrow.textContent = "→";
|
||||
|
||||
link.append(text, arrow);
|
||||
heading.appendChild(link);
|
||||
} else {
|
||||
heading.textContent = chapter.title;
|
||||
}
|
||||
|
||||
body.appendChild(heading);
|
||||
}
|
||||
|
||||
/** DOM builder for json-canvas-viewer `nodeComponents.text` (same markup/classes as list view). */
|
||||
export function renderCatalogChapterCardElement(
|
||||
chapter: CatalogChapter,
|
||||
subjectId: string,
|
||||
variant: "list" | "map" = "map"
|
||||
): HTMLElement {
|
||||
const root = document.createElement("div");
|
||||
root.className = [
|
||||
"catalog-chapter-card",
|
||||
variant === "map" ? "catalog-chapter-card--map" : "",
|
||||
chapter.status === "planned" ? "catalog-chapter-card--planned" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
root.dataset.chapterSlug = chapter.slug;
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "catalog-chapter-card__body";
|
||||
|
||||
if (variant === "map") {
|
||||
appendMapTitle(body, chapter, subjectId);
|
||||
} else {
|
||||
const title = document.createElement("h3");
|
||||
title.className = "catalog-chapter-card__title";
|
||||
title.textContent = chapter.title;
|
||||
body.appendChild(title);
|
||||
}
|
||||
|
||||
if (chapter.description) {
|
||||
const description = document.createElement("p");
|
||||
description.className = "catalog-chapter-card__description";
|
||||
description.textContent = chapter.description;
|
||||
body.appendChild(description);
|
||||
}
|
||||
|
||||
appendMeta(body, chapter);
|
||||
root.appendChild(body);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function MapChapterTitle(props: {
|
||||
chapter: CatalogChapter;
|
||||
subjectId: string;
|
||||
}) {
|
||||
return (
|
||||
<h3 class="catalog-chapter-card__title">
|
||||
<Show
|
||||
when={props.chapter.status === "live"}
|
||||
fallback={props.chapter.title}
|
||||
>
|
||||
<a class="catalog-chapter-card__title-link" href={chapterHref(props.subjectId, props.chapter.slug)}>
|
||||
<span class="catalog-chapter-card__title-text">{props.chapter.title}</span>
|
||||
<span class="catalog-chapter-card__title-arrow" aria-hidden="true">
|
||||
→
|
||||
</span>
|
||||
</a>
|
||||
</Show>
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
export function CatalogChapterCard(props: CatalogChapterCardProps) {
|
||||
const variant = () => props.variant ?? "list";
|
||||
const href = () => chapterHref(props.subjectId, props.chapter.slug);
|
||||
const number = () =>
|
||||
props.index === undefined ? undefined : String(props.index + 1).padStart(2, "0");
|
||||
|
||||
const inner = (
|
||||
<div class="catalog-chapter-card__body">
|
||||
<Show when={variant() === "list" && number()}>
|
||||
<div class="catalog-chapter-card__main">
|
||||
<span class="catalog-chapter-card__number">{number()}</span>
|
||||
<h3 class="catalog-chapter-card__title">{props.chapter.title}</h3>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={variant() === "map"}>
|
||||
<MapChapterTitle chapter={props.chapter} subjectId={props.subjectId} />
|
||||
</Show>
|
||||
<Show when={props.chapter.description}>
|
||||
<p class="catalog-chapter-card__description">{props.chapter.description}</p>
|
||||
</Show>
|
||||
<div class="catalog-chapter-card__meta">
|
||||
<For each={props.chapter.curriculums}>
|
||||
{(curriculum) => (
|
||||
<span class={`catalog-badge ${curriculumBadgeClass(curriculum)}`}>{curriculum}</span>
|
||||
)}
|
||||
</For>
|
||||
<Show when={props.chapter.status === "planned"}>
|
||||
<span class="book-chapter-badge book-chapter-badge--planned">Coming soon</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={props.chapter.status === "live" && variant() === "list"}
|
||||
fallback={
|
||||
<div
|
||||
class="catalog-chapter-card"
|
||||
classList={{
|
||||
"catalog-chapter-card--map": variant() === "map",
|
||||
"catalog-chapter-card--planned": props.chapter.status === "planned",
|
||||
}}
|
||||
data-chapter-slug={props.chapter.slug}
|
||||
>
|
||||
{inner}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
class="catalog-chapter-card"
|
||||
href={href()}
|
||||
data-chapter-slug={props.chapter.slug}
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
97
frontend/src/components/copy-page-button.tsx
Normal file
97
frontend/src/components/copy-page-button.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { createSignal } from "solid-js";
|
||||
import { copyPageAsMarkdown } from "../lib/copy-page-as-markdown";
|
||||
|
||||
type ButtonState = "idle" | "loading" | "copied" | "error";
|
||||
|
||||
const COPIED_RESET_MS = 2000;
|
||||
|
||||
function CopyIcon() {
|
||||
return (
|
||||
<svg
|
||||
class="copy-page-button__icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="5" y="5" width="9" height="9" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.25" />
|
||||
<path
|
||||
d="M3 11V3.5A1.5 1.5 0 0 1 4.5 2H11"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.25"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyPageButton() {
|
||||
const [state, setState] = createSignal<ButtonState>("idle");
|
||||
const [errorMessage, setErrorMessage] = createSignal("");
|
||||
|
||||
const label = () => {
|
||||
switch (state()) {
|
||||
case "loading":
|
||||
return "Preparing…";
|
||||
case "copied":
|
||||
return "Copied!";
|
||||
case "error":
|
||||
return "Copy failed";
|
||||
default:
|
||||
return "Copy page";
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = async () => {
|
||||
if (state() === "loading") {
|
||||
return;
|
||||
}
|
||||
|
||||
setState("loading");
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
await copyPageAsMarkdown();
|
||||
setState("copied");
|
||||
window.setTimeout(() => {
|
||||
if (state() === "copied") {
|
||||
setState("idle");
|
||||
}
|
||||
}, COPIED_RESET_MS);
|
||||
} catch (err) {
|
||||
setState("error");
|
||||
setErrorMessage(err instanceof Error ? err.message : "Could not copy page.");
|
||||
window.setTimeout(() => {
|
||||
if (state() === "error") {
|
||||
setState("idle");
|
||||
setErrorMessage("");
|
||||
}
|
||||
}, COPIED_RESET_MS);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="copy-page-button-wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="copy-page-button"
|
||||
classList={{
|
||||
"copy-page-button--loading": state() === "loading",
|
||||
"copy-page-button--copied": state() === "copied",
|
||||
"copy-page-button--error": state() === "error",
|
||||
}}
|
||||
onClick={handleClick}
|
||||
disabled={state() === "loading"}
|
||||
aria-label={label()}
|
||||
title="Copy page as Markdown for LLMs"
|
||||
>
|
||||
<CopyIcon />
|
||||
<span class="copy-page-button__label">{label()}</span>
|
||||
</button>
|
||||
<p class="copy-page-button__status" aria-live="polite">
|
||||
{state() === "error" ? errorMessage() : state() === "copied" ? "Page copied to clipboard." : ""}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue