v2 #1
13
ui/package-lock.json
generated
13
ui/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^17.0.3",
|
||||
"pocketbase": "^0.26.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
45
ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts
Normal file
45
ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts
Normal file
@@ -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
|
||||
};
|
||||
};
|
||||
85
ui/src/routes/books/[slug]/chapters/[n]/+page.svelte
Normal file
85
ui/src/routes/books/[slug]/chapters/[n]/+page.svelte
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.chapter.title || `Chapter ${data.chapter.number}`} — {data.book.title} — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Top nav -->
|
||||
<div class="flex items-center justify-between mb-6 gap-4">
|
||||
<a
|
||||
href="/books/{data.book.slug}"
|
||||
class="text-zinc-400 hover:text-zinc-100 text-sm flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{data.book.title}
|
||||
</a>
|
||||
|
||||
<div class="flex gap-2">
|
||||
{#if data.prev}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.prev}"
|
||||
class="px-3 py-1.5 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors"
|
||||
>
|
||||
← Ch.{data.prev}
|
||||
</a>
|
||||
{/if}
|
||||
{#if data.next}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.next}"
|
||||
class="px-3 py-1.5 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Ch.{data.next} →
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chapter heading -->
|
||||
<div class="mb-8">
|
||||
<p class="text-zinc-500 text-sm mb-1">Chapter {data.chapter.number}</p>
|
||||
<h1 class="text-xl font-bold text-zinc-100">
|
||||
{data.chapter.title || `Chapter ${data.chapter.number}`}
|
||||
</h1>
|
||||
{#if data.chapter.date_label}
|
||||
<p class="text-zinc-600 text-xs mt-1">{data.chapter.date_label}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Chapter content -->
|
||||
{#if !data.html}
|
||||
<div class="text-zinc-500 text-center py-16">
|
||||
<p>Chapter content not available.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="prose-chapter">
|
||||
{@html data.html}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Bottom nav -->
|
||||
<div class="flex justify-between mt-12 pt-6 border-t border-zinc-800 gap-4">
|
||||
{#if data.prev}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.prev}"
|
||||
class="px-4 py-2 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors"
|
||||
>
|
||||
← Previous chapter
|
||||
</a>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
{#if data.next}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.next}"
|
||||
class="px-4 py-2 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Next chapter →
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user