feat(ui): add homepage with Continue Reading, Recently Updated, and stats
Replace the single-button placeholder with a full homepage: - Stats bar: total books, total chapters, books in progress - Continue Reading grid (up to 6): links directly to last chapter read - Recently Updated grid (up to 6): recent books not already in progress - Empty state with Discover Novels CTA when library is empty New pocketbase.ts helpers: recentlyAddedBooks(), getHomeStats(), listN(), countCollection().
This commit is contained in:
@@ -130,6 +130,25 @@ async function listAll<T>(collection: string, filter = '', sort = ''): Promise<T
|
||||
return data.items ?? [];
|
||||
}
|
||||
|
||||
async function listN<T>(collection: string, n: number, filter = '', sort = ''): Promise<T[]> {
|
||||
const params = new URLSearchParams({ perPage: String(n) });
|
||||
if (filter) params.set('filter', filter);
|
||||
if (sort) params.set('sort', sort);
|
||||
const data = await pbGet<PBList<T>>(
|
||||
`/api/collections/${collection}/records?${params.toString()}`
|
||||
);
|
||||
return data.items ?? [];
|
||||
}
|
||||
|
||||
async function countCollection(collection: string, filter = ''): Promise<number> {
|
||||
const params = new URLSearchParams({ perPage: '1' });
|
||||
if (filter) params.set('filter', filter);
|
||||
const data = await pbGet<PBList<unknown>>(
|
||||
`/api/collections/${collection}/records?${params.toString()}`
|
||||
);
|
||||
return (data as { totalItems: number }).totalItems ?? 0;
|
||||
}
|
||||
|
||||
async function listOne<T>(collection: string, filter: string): Promise<T | null> {
|
||||
const params = new URLSearchParams({ perPage: '1', filter });
|
||||
const data = await pbGet<PBList<T>>(
|
||||
@@ -154,6 +173,27 @@ export async function getBook(slug: string): Promise<Book | null> {
|
||||
return listOne<Book>('books', `slug="${slug}"`);
|
||||
}
|
||||
|
||||
export async function recentlyAddedBooks(limit = 6): Promise<Book[]> {
|
||||
return listN<Book>('books', limit, '', '-meta_updated');
|
||||
}
|
||||
|
||||
export async function recentlyUpdatedBooks(limit = 6): Promise<Book[]> {
|
||||
return listN<Book>('books', limit, '', '-meta_updated');
|
||||
}
|
||||
|
||||
export interface HomeStats {
|
||||
totalBooks: number;
|
||||
totalChapters: number;
|
||||
}
|
||||
|
||||
export async function getHomeStats(): Promise<HomeStats> {
|
||||
const [totalBooks, totalChapters] = await Promise.all([
|
||||
countCollection('books'),
|
||||
countCollection('chapters_idx')
|
||||
]);
|
||||
return { totalBooks, totalChapters };
|
||||
}
|
||||
|
||||
// ─── Chapter index ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listChapterIdx(slug: string): Promise<ChapterIdx[]> {
|
||||
|
||||
49
ui/src/routes/+page.server.ts
Normal file
49
ui/src/routes/+page.server.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import {
|
||||
listBooks,
|
||||
recentlyAddedBooks,
|
||||
allProgress,
|
||||
getHomeStats
|
||||
} from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
import type { Book, Progress } from '$lib/server/pocketbase';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
let allBooks: Book[] = [];
|
||||
let recentBooks: Book[] = [];
|
||||
let progressList: Progress[] = [];
|
||||
let stats = { totalBooks: 0, totalChapters: 0 };
|
||||
|
||||
try {
|
||||
[allBooks, recentBooks, progressList, stats] = await Promise.all([
|
||||
listBooks(),
|
||||
recentlyAddedBooks(8),
|
||||
allProgress(locals.sessionId, locals.user?.id),
|
||||
getHomeStats()
|
||||
]);
|
||||
} catch (e) {
|
||||
log.error('home', 'failed to load home data', { err: String(e) });
|
||||
}
|
||||
|
||||
// Build slug → book lookup
|
||||
const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));
|
||||
|
||||
// Continue reading: progress entries joined with book data, most recent first
|
||||
const continueReading = progressList
|
||||
.filter((p) => bookMap.has(p.slug))
|
||||
.slice(0, 6)
|
||||
.map((p) => ({ book: bookMap.get(p.slug)!, chapter: p.chapter }));
|
||||
|
||||
// Recently updated: deduplicate against continueReading slugs
|
||||
const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug));
|
||||
const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6);
|
||||
|
||||
return {
|
||||
continueReading,
|
||||
recentlyUpdated,
|
||||
stats: {
|
||||
...stats,
|
||||
booksInProgress: continueReading.length
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,149 @@
|
||||
<a href="/books" class="inline-block mt-4 px-6 py-3 bg-amber-400 text-zinc-900 font-semibold rounded hover:bg-amber-300 transition-colors">
|
||||
Browse Library
|
||||
</a>
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
function parseGenres(genres: string[] | string | null | undefined): string[] {
|
||||
if (!genres) return [];
|
||||
if (Array.isArray(genres)) return genres;
|
||||
try {
|
||||
const parsed = JSON.parse(genres);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Stats bar -->
|
||||
<div class="flex gap-6 mb-8 text-center">
|
||||
<div class="flex-1 rounded-lg bg-zinc-800 border border-zinc-700 py-4 px-6">
|
||||
<p class="text-2xl font-bold text-amber-400">{data.stats.totalBooks}</p>
|
||||
<p class="text-xs text-zinc-400 mt-0.5">Books</p>
|
||||
</div>
|
||||
<div class="flex-1 rounded-lg bg-zinc-800 border border-zinc-700 py-4 px-6">
|
||||
<p class="text-2xl font-bold text-amber-400">{data.stats.totalChapters.toLocaleString()}</p>
|
||||
<p class="text-xs text-zinc-400 mt-0.5">Chapters</p>
|
||||
</div>
|
||||
<div class="flex-1 rounded-lg bg-zinc-800 border border-zinc-700 py-4 px-6">
|
||||
<p class="text-2xl font-bold text-amber-400">{data.stats.booksInProgress}</p>
|
||||
<p class="text-xs text-zinc-400 mt-0.5">In progress</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Continue Reading -->
|
||||
{#if data.continueReading.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-lg font-bold text-zinc-100">Continue Reading</h2>
|
||||
<a href="/books" class="text-xs text-amber-400 hover:text-amber-300">View all</a>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each data.continueReading as { book, chapter }}
|
||||
<a
|
||||
href="/books/{book.slug}/chapters/{chapter}"
|
||||
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"
|
||||
>
|
||||
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden relative">
|
||||
{#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-10 h-10" 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}
|
||||
<!-- Chapter badge overlay -->
|
||||
<span class="absolute bottom-1.5 right-1.5 text-xs bg-amber-400 text-zinc-900 font-bold px-1.5 py-0.5 rounded">
|
||||
ch.{chapter}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
{#if book.author}
|
||||
<p class="text-xs text-zinc-500 truncate mt-0.5">{book.author}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Recently Updated -->
|
||||
{#if data.recentlyUpdated.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-lg font-bold text-zinc-100">Recently Updated</h2>
|
||||
<a href="/books" class="text-xs text-amber-400 hover:text-amber-300">View all</a>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each data.recentlyUpdated as book}
|
||||
{@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"
|
||||
>
|
||||
<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-10 h-10" 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>
|
||||
<div class="p-2 flex flex-col gap-1">
|
||||
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
{#if book.author}
|
||||
<p class="text-xs text-zinc-400 truncate">{book.author}</p>
|
||||
{/if}
|
||||
{#if book.status}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-300 self-start">{book.status}</span>
|
||||
{/if}
|
||||
{#if genres.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-auto pt-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>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Empty state -->
|
||||
{#if data.continueReading.length === 0 && data.recentlyUpdated.length === 0}
|
||||
<div class="text-center py-20 text-zinc-500">
|
||||
<p class="text-lg font-semibold text-zinc-300 mb-2">Your library is empty</p>
|
||||
<p class="text-sm mb-6">Discover novels and scrape them into your library.</p>
|
||||
<a
|
||||
href="/browse"
|
||||
class="inline-block px-6 py-3 bg-amber-400 text-zinc-900 font-semibold rounded hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Discover Novels
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user