feat(ui): add book list page and book detail page with chapter index
This commit is contained in:
17
ui/src/routes/books/+page.server.ts
Normal file
17
ui/src/routes/books/+page.server.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { listBooks, allProgress } from '$lib/server/pocketbase';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const [books, progressList] = await Promise.all([
|
||||
listBooks(),
|
||||
allProgress(locals.sessionId)
|
||||
]);
|
||||
|
||||
// Build a quick lookup: slug → last chapter read
|
||||
const progressMap: Record<string, number> = {};
|
||||
for (const p of progressList) {
|
||||
progressMap[p.slug] = p.chapter;
|
||||
}
|
||||
|
||||
return { books, progressMap };
|
||||
};
|
||||
91
ui/src/routes/books/+page.svelte
Normal file
91
ui/src/routes/books/+page.svelte
Normal file
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
function parseGenres(genres: string[] | string): string[] {
|
||||
if (Array.isArray(genres)) return genres;
|
||||
try {
|
||||
return JSON.parse(genres);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Library — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-zinc-100">Library</h1>
|
||||
<p class="text-zinc-400 text-sm mt-1">{data.books.length} books</p>
|
||||
</div>
|
||||
|
||||
{#if data.books.length === 0}
|
||||
<div class="text-center py-20 text-zinc-500">
|
||||
<p class="text-lg">No books scraped yet.</p>
|
||||
<p class="text-sm mt-2">Use the scraper API to add books.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{#each data.books as book}
|
||||
{@const lastChapter = data.progressMap[book.slug]}
|
||||
{@const genres = parseGenres(book.genres)}
|
||||
<a
|
||||
href="/books/{book.slug}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
|
||||
>
|
||||
<!-- Cover image -->
|
||||
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden">
|
||||
{#if book.cover}
|
||||
<img
|
||||
src={book.cover}
|
||||
alt={book.title}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-zinc-600">
|
||||
<svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="p-2 flex flex-col gap-1 flex-1">
|
||||
<h2 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">
|
||||
{book.title}
|
||||
</h2>
|
||||
{#if book.author}
|
||||
<p class="text-xs text-zinc-400 truncate">{book.author}</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-auto pt-1 flex items-center justify-between gap-1">
|
||||
{#if book.status}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-300 truncate max-w-[60%]">
|
||||
{book.status}
|
||||
</span>
|
||||
{/if}
|
||||
{#if lastChapter}
|
||||
<span class="text-xs text-amber-400 font-medium ml-auto whitespace-nowrap">
|
||||
ch.{lastChapter}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if genres.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
{#each genres.slice(0, 2) as genre}
|
||||
<span class="text-xs px-1 py-0.5 rounded bg-zinc-900 text-zinc-500">{genre}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
23
ui/src/routes/books/[slug]/+page.server.ts
Normal file
23
ui/src/routes/books/[slug]/+page.server.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getBook, listChapterIdx, getProgress } from '$lib/server/pocketbase';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
const { slug } = params;
|
||||
|
||||
const [book, chapters, progress] = await Promise.all([
|
||||
getBook(slug),
|
||||
listChapterIdx(slug),
|
||||
getProgress(locals.sessionId, slug)
|
||||
]);
|
||||
|
||||
if (!book) {
|
||||
error(404, `Book "${slug}" not found`);
|
||||
}
|
||||
|
||||
return {
|
||||
book,
|
||||
chapters,
|
||||
lastChapter: progress?.chapter ?? null
|
||||
};
|
||||
};
|
||||
142
ui/src/routes/books/[slug]/+page.svelte
Normal file
142
ui/src/routes/books/[slug]/+page.svelte
Normal file
@@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
function parseGenres(genres: string[] | string): string[] {
|
||||
if (Array.isArray(genres)) return genres;
|
||||
try {
|
||||
return JSON.parse(genres);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const genres = $derived(parseGenres(data.book.genres));
|
||||
|
||||
// Paginate chapter list — show 100 at a time
|
||||
let page = $state(0);
|
||||
const PAGE_SIZE = 100;
|
||||
const totalPages = $derived(Math.ceil(data.chapters.length / PAGE_SIZE));
|
||||
const visibleChapters = $derived(
|
||||
data.chapters.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.book.title} — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Book header -->
|
||||
<div class="flex gap-6 mb-8">
|
||||
{#if data.book.cover}
|
||||
<img
|
||||
src={data.book.cover}
|
||||
alt={data.book.title}
|
||||
class="w-32 sm:w-40 rounded-lg object-cover flex-shrink-0 border border-zinc-700"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-2 min-w-0">
|
||||
<h1 class="text-2xl font-bold text-zinc-100 leading-tight">{data.book.title}</h1>
|
||||
|
||||
{#if data.book.author}
|
||||
<p class="text-zinc-400 text-sm">{data.book.author}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap gap-2 mt-1">
|
||||
{#if data.book.status}
|
||||
<span class="text-xs px-2 py-1 rounded bg-zinc-700 text-zinc-300">{data.book.status}</span>
|
||||
{/if}
|
||||
{#each genres as genre}
|
||||
<span class="text-xs px-2 py-1 rounded bg-zinc-800 text-zinc-400">{genre}</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if data.book.summary}
|
||||
<p class="text-zinc-400 text-sm leading-relaxed line-clamp-4 mt-1">{data.book.summary}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3 mt-2">
|
||||
{#if data.lastChapter}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.lastChapter}"
|
||||
class="px-4 py-2 bg-amber-400 text-zinc-900 font-semibold rounded text-sm hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Continue ch.{data.lastChapter}
|
||||
</a>
|
||||
{/if}
|
||||
{#if data.chapters.length > 0}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/1"
|
||||
class="px-4 py-2 bg-zinc-700 text-zinc-100 font-semibold rounded text-sm hover:bg-zinc-600 transition-colors"
|
||||
>
|
||||
Start from ch.1
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chapter list -->
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-lg font-semibold text-zinc-100">
|
||||
Chapters
|
||||
<span class="text-zinc-500 font-normal text-sm ml-1">({data.chapters.length})</span>
|
||||
</h2>
|
||||
|
||||
{#if totalPages > 1}
|
||||
<div class="flex gap-2 items-center text-sm">
|
||||
<button
|
||||
onclick={() => (page = Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
class="px-2 py-1 rounded bg-zinc-700 text-zinc-300 disabled:opacity-40 hover:bg-zinc-600 transition-colors"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<span class="text-zinc-400">{page + 1} / {totalPages}</span>
|
||||
<button
|
||||
onclick={() => (page = Math.min(totalPages - 1, page + 1))}
|
||||
disabled={page === totalPages - 1}
|
||||
class="px-2 py-1 rounded bg-zinc-700 text-zinc-300 disabled:opacity-40 hover:bg-zinc-600 transition-colors"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if data.chapters.length === 0}
|
||||
<p class="text-zinc-500 text-sm">No chapters available yet.</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-1">
|
||||
{#each visibleChapters as chapter}
|
||||
{@const isCurrent = data.lastChapter === chapter.number}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{chapter.number}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded hover:bg-zinc-800 transition-colors group {isCurrent
|
||||
? 'bg-zinc-800'
|
||||
: ''}"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-mono w-10 text-right flex-shrink-0 {isCurrent
|
||||
? 'text-amber-400'
|
||||
: 'text-zinc-500'}"
|
||||
>
|
||||
{chapter.number}
|
||||
</span>
|
||||
<span class="text-sm text-zinc-300 group-hover:text-zinc-100 truncate flex-1">
|
||||
{chapter.title || `Chapter ${chapter.number}`}
|
||||
</span>
|
||||
{#if isCurrent}
|
||||
<span class="text-xs text-amber-400 flex-shrink-0">reading</span>
|
||||
{/if}
|
||||
{#if chapter.date_label}
|
||||
<span class="text-xs text-zinc-600 flex-shrink-0 hidden sm:block">{chapter.date_label}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user