feat: book detail refactor — compact chapters row + reader UX improvements
Some checks failed
CI / Scraper / Lint (pull_request) Failing after 6s
CI / Scraper / Test (pull_request) Successful in 18s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 16s
CI / UI / Build (push) Successful in 24s
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (push) Successful in 35s
iOS CI / Build (push) Successful in 4m34s
iOS CI / Build (pull_request) Successful in 4m48s
iOS CI / Test (push) Has started running
iOS CI / Test (pull_request) Successful in 5m59s

iOS BookDetailView: replace paginated inline chapter list with a single
tappable 'Chapters' row (showing reading progress) that opens
BookChaptersSheet — a searchable full-screen sheet with jump-to-current.

ChapterReaderView: hide tab bar in reader, swap back/Aa/ToC button order
(Aa left, ToC right, X rightmost), remove mini-player spacer (tab bar
and player are hidden).

HomeView: remove large HeroContinueCard, promote all continue-reading
items into a single horizontal shelf (Apple Books style) with progress
bar below each cover. NavigationLink now goes directly to the chapter.

Web +page.svelte: replace inline paginated chapter list with a compact
'Chapters' row linking to /books/[slug]/chapters. Admin scrape controls
are now a collapsible row inside the same card.
This commit is contained in:
Admin
2026-03-10 21:51:18 +05:00
parent 4d3c093612
commit 81265510ef
5 changed files with 292 additions and 405 deletions

View File

@@ -1,6 +1,4 @@
<script lang="ts">
import { onMount } from 'svelte';
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import CommentsSection from '$lib/components/CommentsSection.svelte';
@@ -33,57 +31,12 @@
const genres = $derived(parseGenres(data.book.genres));
// Paginate chapter list — 50 on mobile, 100 on sm+ (≥640px)
let pageSize = $state(50);
onMount(() => {
const mq = window.matchMedia('(min-width: 640px)');
pageSize = mq.matches ? 100 : 50;
const handler = (e: MediaQueryListEvent) => { pageSize = e.matches ? 100 : 50; };
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
});
// Start on the page that contains the current chapter (if any)
function pageForChapter(chapterNum: number | null, list: typeof chapterList): number {
if (!chapterNum || list.length === 0) return 0;
const idx = list.findIndex((c) => c.number === chapterNum);
if (idx === -1) return 0;
return Math.floor(idx / pageSize);
}
let page = $state(pageForChapter(data.lastChapter, data.inLib ? data.chapters : (data.previewChapters ?? [])));
// Use preview chapters if the book is not in the library
// Use preview chapters if the book is not in the library (needed for chapter count)
const chapterList = $derived(
data.inLib
? data.chapters
: (data.previewChapters ?? [])
);
const totalPages = $derived(Math.ceil(chapterList.length / pageSize));
const visibleChapters = $derived(
chapterList.slice(page * pageSize, (page + 1) * pageSize)
);
// ── Chapter list polling ──────────────────────────────────────────────────
// When the book was just added to the library via preview (inLib=true but
// no chapters yet), poll until the background WriteChapterRefs completes.
let pollingChapters = $state(data.inLib && data.chapters.length === 0);
onMount(() => {
if (!pollingChapters) return;
let attempts = 0;
const MAX_ATTEMPTS = 20; // ~10 seconds
const timer = setInterval(async () => {
attempts++;
await invalidateAll();
if (data.chapters.length > 0 || attempts >= MAX_ATTEMPTS) {
pollingChapters = false;
clearInterval(timer);
}
}, 500);
return () => clearInterval(timer);
});
// ── Admin: rescrape ───────────────────────────────────────────────────────
let scraping = $state(false);
@@ -138,26 +91,6 @@
}
}
async function scrapeFromChapter(n: number) {
if (rangeScraping || !data.book.source_url) return;
rangeScraping = true;
rangeResult = '';
try {
const res = await fetch('/api/scrape/range', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: data.book.source_url, from: n })
});
if (res.ok) rangeResult = 'queued';
else if (res.status === 409) rangeResult = 'busy';
else rangeResult = 'error';
} catch {
rangeResult = 'error';
} finally {
rangeScraping = false;
}
}
// ── Summary expand/collapse ───────────────────────────────────────────────
let summaryExpanded = $state(false);
@@ -342,101 +275,36 @@
</div>
</div>
<!-- ══════════════════════════════════════════════════ Chapter list ══ -->
<div>
<!-- Header row: title + pagination -->
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
<h2 class="text-base font-semibold text-zinc-200">
Chapters
<!-- ══════════════════════════════════════════════════ Chapters row ══ -->
<div class="flex flex-col divide-y divide-zinc-800 border border-zinc-800 rounded-xl overflow-hidden mb-6">
<!-- Chapters row: links to the full chapter list page -->
<a
href="/books/{data.book.slug}/chapters"
class="flex items-center gap-3 px-4 py-3.5 hover:bg-zinc-800/60 transition-colors group"
>
<svg class="w-4 h-4 text-amber-400 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h10"/>
</svg>
<div class="flex flex-col min-w-0 flex-1">
<span class="text-sm font-semibold text-zinc-200">Chapters</span>
{#if chapterList.length > 0}
<span class="text-zinc-500 font-normal text-sm ml-1">({chapterList.length})</span>
{/if}
</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-800 text-zinc-300 disabled:opacity-40 hover:bg-zinc-700 transition-colors"
>
&larr;
</button>
<span class="text-zinc-500 text-xs tabular-nums">
{page * pageSize + 1}{Math.min((page + 1) * pageSize, chapterList.length)} of {chapterList.length}
</span>
<button
onclick={() => (page = Math.min(totalPages - 1, page + 1))}
disabled={page === totalPages - 1}
class="px-2 py-1 rounded bg-zinc-800 text-zinc-300 disabled:opacity-40 hover:bg-zinc-700 transition-colors"
>
&rarr;
</button>
</div>
{/if}
</div>
<!-- Chapter rows -->
{#if pollingChapters}
<div class="flex items-center gap-3 py-4 text-zinc-500 text-sm">
<svg class="w-4 h-4 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Indexing chapter list…
</div>
<div class="flex flex-col gap-0.5 opacity-40 pointer-events-none">
{#each Array(8) as _}
<div class="h-9 rounded bg-zinc-800 animate-pulse"></div>
{/each}
</div>
{:else if chapterList.length === 0}
<p class="text-zinc-500 text-sm">No chapters available yet.</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each visibleChapters as chapter}
{@const isCurrent = data.lastChapter === chapter.number}
{@const chapterUrl = data.inLib
? `/books/${data.book.slug}/chapters/${chapter.number}`
: `/books/${data.book.slug}/chapters/${chapter.number}?preview=1&chapter_url=${encodeURIComponent((chapter as { url?: string }).url ?? '')}&title=${encodeURIComponent(chapter.title ?? '')}`}
<div class="flex items-center gap-2 px-3 py-2.5 rounded hover:bg-zinc-800/70 transition-colors group {isCurrent ? 'bg-zinc-800' : ''}">
<a href={chapterUrl} class="flex items-center gap-2 flex-1 min-w-0">
<!-- Chapter number -->
<span class="text-sm font-mono w-10 text-right flex-shrink-0 {isCurrent ? 'text-amber-400' : 'text-zinc-600'}">
{chapter.number}
</span>
<!-- Title -->
<span class="text-base {isCurrent ? 'text-amber-300' : 'text-zinc-300 group-hover:text-zinc-100'} truncate min-w-0 flex-1 transition-colors">
{chapter.title || `Chapter ${chapter.number}`}
</span>
<!-- Date label — desktop only -->
{#if (chapter as { date_label?: string }).date_label}
<span class="text-sm text-zinc-600 flex-shrink-0 max-sm:hidden">&middot; {(chapter as { date_label?: string }).date_label}</span>
{/if}
<!-- "reading" badge -->
{#if isCurrent}
<span class="text-sm text-amber-500 flex-shrink-0 font-medium">reading</span>
{/if}
</a>
<!-- Admin: scrape from this chapter up (hover-only) -->
{#if data.isAdmin && data.book.source_url && data.inLib}
<button
onclick={() => scrapeFromChapter(chapter.number)}
disabled={rangeScraping}
class="opacity-0 group-hover:opacity-100 shrink-0 text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-500 hover:bg-amber-500/30 transition-all border border-amber-500/20 disabled:opacity-30"
title="Scrape from chapter {chapter.number} up"
>
↑ here
</button>
<span class="text-xs text-zinc-500">
{#if data.lastChapter && data.lastChapter > 0}
Reading ch.{data.lastChapter} of {chapterList.length}
{:else}
{chapterList.length} chapter{chapterList.length === 1 ? '' : 's'}
{/if}
</div>
{/each}
</span>
{/if}
</div>
{/if}
<svg class="w-4 h-4 text-zinc-600 group-hover:text-zinc-400 transition-colors flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</a>
<!-- ── Admin panel (collapsed by default) ── -->
<!-- Admin panel (collapsed by default, admin only) -->
{#if data.isAdmin && data.book.source_url}
<div class="mt-6 border border-zinc-800 rounded-lg overflow-hidden">
<div>
<button
onclick={() => (adminOpen = !adminOpen)}
class="w-full flex items-center gap-2 px-4 py-2.5 text-xs font-medium text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/50 transition-colors text-left"