- New Go backend binary (backend + runner) replacing old scraper/ - Rename SCRAPER_API_URL → BACKEND_API_URL in UI env and docker-compose - Rename scraperFetch → backendFetch across all 19 UI server files - Remove SCRAPER_PROXY env var and proxy transport from browser.Config - Add Meilisearch, Valkey, Caddy to docker-compose - Add docs/: api-endpoints.md, request-flow.mermaid.md, data-flow.mermaid.md
203 lines
8.0 KiB
Svelte
203 lines
8.0 KiB
Svelte
<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}
|
|
|
|
<!-- From Subscriptions -->
|
|
{#if data.subscriptionFeed.length > 0}
|
|
<section class="mb-10">
|
|
<div class="flex items-baseline justify-between mb-3">
|
|
<h2 class="text-lg font-bold text-zinc-100">From People You Follow</h2>
|
|
</div>
|
|
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
|
{#each data.subscriptionFeed as { book, readerUsername }}
|
|
{@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}
|
|
<!-- Reader attribution -->
|
|
<p class="text-xs text-zinc-600 truncate mt-0.5">
|
|
via <span class="text-amber-500/70">{readerUsername}</span>
|
|
</p>
|
|
{#if genres.length > 0}
|
|
<div class="flex flex-wrap gap-1 mt-auto pt-1">
|
|
{#each genres.slice(0, 1) 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}
|