diff --git a/ui/package-lock.json b/ui/package-lock.json
index 2ae568b..af9008e 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -8,6 +8,7 @@
"name": "ui",
"version": "0.0.1",
"dependencies": {
+ "marked": "^17.0.3",
"pocketbase": "^0.26.8"
},
"devDependencies": {
@@ -1979,6 +1980,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/marked": {
+ "version": "17.0.3",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.3.tgz",
+ "integrity": "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
diff --git a/ui/package.json b/ui/package.json
index bb5ab5b..01bca59 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -24,6 +24,7 @@
"vite": "^7.3.1"
},
"dependencies": {
+ "marked": "^17.0.3",
"pocketbase": "^0.26.8"
}
}
diff --git a/ui/src/app.css b/ui/src/app.css
index 7bc294a..e7bc911 100644
--- a/ui/src/app.css
+++ b/ui/src/app.css
@@ -14,3 +14,42 @@ html {
background-color: var(--color-surface);
color: var(--color-text);
}
+
+/* ── Chapter prose ─────────────────────────────────────────────────── */
+.prose-chapter {
+ max-width: 72ch;
+ line-height: 1.85;
+ font-size: 1.05rem;
+ color: #d4d4d8; /* zinc-300 */
+}
+
+.prose-chapter h1,
+.prose-chapter h2,
+.prose-chapter h3 {
+ color: #f4f4f5;
+ font-weight: 700;
+ margin-top: 1.5em;
+ margin-bottom: 0.5em;
+}
+
+.prose-chapter h1 { font-size: 1.4rem; }
+.prose-chapter h2 { font-size: 1.2rem; }
+.prose-chapter h3 { font-size: 1.05rem; }
+
+.prose-chapter p {
+ margin-bottom: 1.2em;
+}
+
+.prose-chapter em {
+ color: #a1a1aa;
+}
+
+.prose-chapter strong {
+ color: #f4f4f5;
+}
+
+.prose-chapter hr {
+ border-color: #3f3f46;
+ margin: 2em 0;
+}
+
diff --git a/ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts b/ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts
new file mode 100644
index 0000000..3df478b
--- /dev/null
+++ b/ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts
@@ -0,0 +1,45 @@
+import { error } from '@sveltejs/kit';
+import { marked } from 'marked';
+import type { PageServerLoad } from './$types';
+import { getBook, listChapterIdx } from '$lib/server/pocketbase';
+import { presignChapter } from '$lib/server/minio';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ const { slug } = params;
+ const n = parseInt(params.n, 10);
+
+ if (!n || n < 1) error(400, 'Invalid chapter number');
+
+ // Fetch book metadata and chapter index in parallel
+ const [book, chapters] = await Promise.all([getBook(slug), listChapterIdx(slug)]);
+
+ if (!book) error(404, `Book "${slug}" not found`);
+
+ const chapterIdx = chapters.find((c) => c.number === n);
+ if (!chapterIdx) error(404, `Chapter ${n} not found`);
+
+ // Get presigned URL and fetch chapter markdown server-side
+ let html = '';
+ try {
+ const url = await presignChapter(slug, n);
+ const res = await fetch(url);
+ if (!res.ok) throw new Error(`MinIO returned ${res.status}`);
+ const markdown = await res.text();
+ html = await marked(markdown, { async: true });
+ } catch (e) {
+ // Don't hard-fail — show empty content with error message
+ console.error('Failed to fetch chapter content:', e);
+ }
+
+ const prevChapter = chapters.find((c) => c.number === n - 1) ?? null;
+ const nextChapter = chapters.find((c) => c.number === n + 1) ?? null;
+
+ return {
+ book: { slug: book.slug, title: book.title },
+ chapter: chapterIdx,
+ html,
+ prev: prevChapter ? prevChapter.number : null,
+ next: nextChapter ? nextChapter.number : null,
+ sessionId: locals.sessionId
+ };
+};
diff --git a/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte b/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte
new file mode 100644
index 0000000..e4706fb
--- /dev/null
+++ b/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte
@@ -0,0 +1,85 @@
+
+
+
Chapter {data.chapter.number}
+{data.chapter.date_label}
+ {/if} +Chapter content not available.
+