feat(ui): add book list page and book detail page with chapter index

This commit is contained in:
Admin
2026-03-02 21:37:32 +05:00
parent 33e2a4dc01
commit 4ae6f0ab42
4 changed files with 273 additions and 0 deletions

View 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 };
};

View 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}

View 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
};
};

View 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"
>
&larr;
</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"
>
&rarr;
</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>