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

@@ -7,8 +7,7 @@ struct BookDetailView: View {
@EnvironmentObject var authStore: AuthStore
@EnvironmentObject var audioPlayer: AudioPlayerService
@State private var summaryExpanded = false
@State private var chapterPage = 0
private let pageSize = 50
@State private var showChapters = false
init(slug: String) {
self.slug = slug
@@ -17,7 +16,6 @@ struct BookDetailView: View {
var body: some View {
ZStack(alignment: .top) {
// Scroll content
ScrollView {
VStack(alignment: .leading, spacing: 0) {
if vm.isLoading {
@@ -26,7 +24,7 @@ struct BookDetailView: View {
heroSection(book: book)
metaSection(book: book)
Divider().padding(.horizontal)
chapterSection(book: book)
chaptersRow(book: book)
Divider().padding(.horizontal)
CommentsView(slug: slug)
}
@@ -38,6 +36,14 @@ struct BookDetailView: View {
.toolbar { bookmarkButton }
.task { await vm.load() }
.errorAlert($vm.error)
.sheet(isPresented: $showChapters) {
BookChaptersSheet(
slug: slug,
chapters: vm.chapters,
lastChapter: vm.lastChapter,
totalChapters: vm.book?.totalChapters ?? 0
)
}
}
// MARK: - Hero
@@ -61,9 +67,7 @@ struct BookDetailView: View {
)
)
// Cover + info column centered
VStack(spacing: 16) {
// Isolated cover with 3D-style shadow
KFImage(URL(string: book.cover))
.resizable()
.placeholder {
@@ -76,7 +80,6 @@ struct BookDetailView: View {
.shadow(color: .black.opacity(0.55), radius: 18, x: 0, y: 10)
.shadow(color: .black.opacity(0.3), radius: 6, x: 0, y: 3)
// Title + author
VStack(spacing: 6) {
Text(book.title)
.font(.title3.bold())
@@ -90,7 +93,6 @@ struct BookDetailView: View {
.foregroundStyle(.white.opacity(0.75))
}
// Genre tags
if !book.genres.isEmpty {
HStack(spacing: 8) {
ForEach(book.genres.prefix(3), id: \.self) { genre in
@@ -99,7 +101,6 @@ struct BookDetailView: View {
}
}
// Status badge
if !book.status.isEmpty {
StatusBadge(status: book.status)
}
@@ -110,22 +111,22 @@ struct BookDetailView: View {
.frame(minHeight: 320)
}
// MARK: - Meta section (summary + CTAs)
// MARK: - Meta section (stats + summary + CTAs)
@ViewBuilder
private func metaSection(book: Book) -> some View {
VStack(alignment: .leading, spacing: 0) {
// Quick stats row
HStack(spacing: 0) {
MetaStat(value: "\(book.totalChapters)", label: "Chapters",
icon: "doc.text")
MetaStat(value: "\(book.totalChapters)", label: "Chapters", icon: "doc.text")
Divider().frame(height: 36)
MetaStat(value: book.status.capitalized.isEmpty ? "" : book.status.capitalized,
label: "Status", icon: "flag")
MetaStat(
value: book.status.capitalized.isEmpty ? "" : book.status.capitalized,
label: "Status", icon: "flag"
)
if book.ranking > 0 {
Divider().frame(height: 36)
MetaStat(value: "#\(book.ranking)", label: "Rank",
icon: "chart.bar.fill")
MetaStat(value: "#\(book.ranking)", label: "Rank", icon: "chart.bar.fill")
}
}
.padding(.vertical, 16)
@@ -169,7 +170,7 @@ struct BookDetailView: View {
.tint(.amber)
NavigationLink(value: NavDestination.chapter(slug, 1)) {
Label("Ch.1", systemImage: "arrow.counterclockwise")
Label("From Ch.1", systemImage: "arrow.counterclockwise")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
@@ -189,78 +190,49 @@ struct BookDetailView: View {
}
}
// MARK: - Chapter list
// MARK: - Compact chapters row (tap sheet)
@ViewBuilder
private func chapterSection(book: Book) -> some View {
let chapters = vm.chapters
let total = chapters.count
let start = chapterPage * pageSize
let end = min(start + pageSize, total)
let pageChapters = Array(chapters[start..<end])
private func chaptersRow(book: Book) -> some View {
Button {
showChapters = true
} label: {
HStack(spacing: 12) {
Image(systemName: "list.number")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.amber)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text("Chapters")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
if !vm.chapters.isEmpty {
let last = vm.lastChapter
let total = vm.chapters.count
Text(last != nil && last! > 0
? "Reading Ch.\(last!) of \(total)"
: "\(total) chapter\(total == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(.secondary)
} else if vm.isLoading {
Text("Loading…")
.font(.caption)
.foregroundStyle(.secondary)
}
}
VStack(alignment: .leading, spacing: 0) {
// Section header
HStack {
Text("Chapters")
.font(.headline)
Spacer()
if total > 0 {
Text("\(start + 1)\(end) of \(total)")
.font(.caption)
.foregroundStyle(.secondary)
}
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
.padding(.horizontal)
.padding(.horizontal, 16)
.padding(.vertical, 14)
if vm.isLoading {
ProgressView().frame(maxWidth: .infinity).padding()
} else {
ForEach(pageChapters) { ch in
NavigationLink(value: NavDestination.chapter(slug, ch.number)) {
ChapterRow(chapter: ch, isCurrent: ch.number == vm.lastChapter,
totalChapters: total)
}
.buttonStyle(.plain)
Divider().padding(.leading)
}
}
// Pagination bar
if total > pageSize {
HStack {
Button {
withAnimation { chapterPage -= 1 }
} label: {
Image(systemName: "chevron.left")
Text("Previous")
}
.disabled(chapterPage == 0)
Spacer()
Text("Page \(chapterPage + 1) of \((total + pageSize - 1) / pageSize)")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button {
withAnimation { chapterPage += 1 }
} label: {
Text("Next")
Image(systemName: "chevron.right")
}
.disabled(end >= total)
}
.font(.subheadline)
.foregroundStyle(.amber)
.padding()
}
Color.clear.frame(height: 32)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
// MARK: - Bookmark toolbar
@@ -278,20 +250,149 @@ struct BookDetailView: View {
}
}
// MARK: - Chapter row
// MARK: - Chapters list sheet
struct BookChaptersSheet: View {
let slug: String
let chapters: [ChapterIndex]
let lastChapter: Int?
let totalChapters: Int
@Environment(\.dismiss) private var dismiss
@State private var searchText = ""
@State private var scrollToCurrentOnAppear = true
private var filtered: [ChapterIndex] {
guard !searchText.isEmpty else { return chapters }
let q = searchText.lowercased()
return chapters.filter {
"chapter \($0.number)".contains(q) ||
$0.title.lowercased().contains(q)
}
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Search bar
HStack(spacing: 8) {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
TextField("Search chapters…", text: $searchText)
.autocorrectionDisabled()
if !searchText.isEmpty {
Button { searchText = "" } label: {
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
}
}
}
.padding(10)
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
.padding(.horizontal)
.padding(.vertical, 10)
Divider()
// Jump-to-current banner (shown when user has progress and not searching)
if let last = lastChapter, last > 0, searchText.isEmpty {
Button {
scrollToCurrentOnAppear = true
} label: {
HStack(spacing: 8) {
Image(systemName: "arrow.down.circle.fill")
.foregroundStyle(.amber)
Text("Jump to Ch.\(last)")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.amber)
Spacer()
let pct = totalChapters > 0
? Int(Double(last) / Double(totalChapters) * 100)
: 0
Text("\(pct)% read")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
.buttonStyle(.plain)
Divider()
}
if chapters.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if filtered.isEmpty {
VStack(spacing: 10) {
Image(systemName: "magnifyingglass")
.font(.largeTitle)
.foregroundStyle(.secondary)
Text("No chapters match \"\(searchText)\"")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollViewReader { proxy in
List {
ForEach(filtered) { ch in
NavigationLink(value: NavDestination.chapter(slug, ch.number)) {
ChapterRow(
chapter: ch,
isCurrent: ch.number == lastChapter,
totalChapters: chapters.count
)
}
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16))
.id(ch.number)
}
}
.listStyle(.plain)
.appNavigationDestination()
.onAppear {
if scrollToCurrentOnAppear, let last = lastChapter, last > 0 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
withAnimation {
proxy.scrollTo(last, anchor: .center)
}
}
scrollToCurrentOnAppear = false
}
}
.onChange(of: scrollToCurrentOnAppear) { _, jump in
if jump, let last = lastChapter, last > 0 {
withAnimation {
proxy.scrollTo(last, anchor: .center)
}
scrollToCurrentOnAppear = false
}
}
}
}
}
.navigationTitle("Chapters")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
.fontWeight(.semibold)
}
}
}
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
}
// MARK: - Chapter row (reused by sheet)
private struct ChapterRow: View {
let chapter: ChapterIndex
let isCurrent: Bool
let totalChapters: Int
private var progressFraction: Double {
guard totalChapters > 1 else { return 0 }
return Double(chapter.number) / Double(totalChapters)
}
var body: some View {
HStack(spacing: 10) {
HStack(spacing: 12) {
// Number badge
ZStack {
Circle()
@@ -299,6 +400,7 @@ private struct ChapterRow: View {
Text("\(chapter.number)")
.font(.caption2.bold().monospacedDigit())
.foregroundStyle(isCurrent ? .black : .secondary)
.minimumScaleFactor(0.6)
}
.frame(width: 32, height: 32)
@@ -316,11 +418,7 @@ private struct ChapterRow: View {
.fontWeight(isCurrent ? .semibold : .regular)
.foregroundStyle(isCurrent ? .amber : .primary)
.lineLimit(1)
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 2) {
if !chapter.dateLabel.isEmpty {
Text(chapter.dateLabel)
.font(.caption2)
@@ -328,6 +426,8 @@ private struct ChapterRow: View {
}
}
Spacer(minLength: 8)
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(.tertiary)

View File

@@ -68,6 +68,7 @@ struct ChapterReaderView: View {
}
.navigationBarHidden(true) // we draw our own chrome
.toolbar(.hidden, for: .tabBar) // hide tab bar in reader (Apple Books style)
.ignoresSafeArea(edges: .top)
.preferredColorScheme(readerSettings.settings.theme.colorScheme)
.task(id: currentChapter) { await vm.load() }
@@ -125,30 +126,7 @@ struct ChapterReaderView: View {
.blur(radius: 0)
HStack(spacing: 0) {
// Back button
Button {
dismiss()
} label: {
Image(systemName: "chevron.left")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.85))
.frame(width: 44, height: 44)
}
Spacer()
// Chapter title (truncated)
if let content = vm.content {
Text(content.chapter.title.strippingTrailingDate())
.font(.subheadline.weight(.medium))
.foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.7))
.lineLimit(1)
.frame(maxWidth: 220)
}
Spacer()
// Aa settings button
// Left: Aa settings button
Button {
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
showSettingsPanel.toggle()
@@ -160,6 +138,19 @@ struct ChapterReaderView: View {
.frame(width: 44, height: 44)
}
Spacer()
// Center: Chapter title (truncated)
if let content = vm.content {
Text(content.chapter.title.strippingTrailingDate())
.font(.subheadline.weight(.medium))
.foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.7))
.lineLimit(1)
.frame(maxWidth: 200)
}
Spacer()
// ToC button
Button {
showToCSheet = true
@@ -169,6 +160,16 @@ struct ChapterReaderView: View {
.foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.85))
.frame(width: 44, height: 44)
}
// X dismiss button (rightmost)
Button {
dismiss()
} label: {
Image(systemName: "xmark")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.85))
.frame(width: 44, height: 44)
}
}
.padding(.horizontal, 4)
}
@@ -268,12 +269,7 @@ struct ChapterReaderView: View {
}
.frame(height: 60)
// Mini player spacer if active
if audioPlayer.isActive {
Color.clear.frame(height: AppLayout.miniPlayerBarHeight)
}
// Home indicator area
// Home indicator area (no mini player spacer tab bar and mini player are hidden in reader)
Color.clear.frame(height: safeAreaBottom)
}
.animation(.easeInOut(duration: 0.2), value: chromeVisible)

View File

@@ -9,22 +9,14 @@ struct HomeView: View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
// Large hero continue card (most recent in-progress book)
if let hero = vm.continueReading.first {
HeroContinueCard(item: hero)
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 28)
}
// Continue reading shelf (remaining items after the hero)
let shelf = vm.continueReading.dropFirst()
if !shelf.isEmpty {
// Continue reading all in-progress books as a horizontal shelf (Apple Books style)
if !vm.continueReading.isEmpty {
ShelfHeader(title: "Continue Reading")
.padding(.top, 8)
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 14) {
ForEach(Array(shelf)) { item in
NavigationLink(value: NavDestination.book(item.book.slug)) {
HStack(alignment: .top, spacing: 16) {
ForEach(vm.continueReading) { item in
NavigationLink(value: NavDestination.chapter(item.book.slug, item.chapter)) {
ContinueReadingCard(item: item)
}
.buttonStyle(.plain)
@@ -95,96 +87,6 @@ struct HomeView: View {
}
}
// MARK: - Hero card (full-width, Apple Books "Now Playing" style)
private struct HeroContinueCard: View {
let item: ContinueReadingItem
var body: some View {
NavigationLink(value: NavDestination.chapter(item.book.slug, item.chapter)) {
ZStack(alignment: .bottomLeading) {
// Blurred background
AsyncCoverImage(url: item.book.cover, isBackground: true)
.frame(maxWidth: .infinity)
.frame(height: 220)
.blur(radius: 22)
.clipped()
// Depth gradient: subtle amber tint at top, deep shadow at bottom
.overlay(
LinearGradient(
stops: [
.init(color: Color(red: 0.18, green: 0.12, blue: 0.02).opacity(0.55), location: 0),
.init(color: .black.opacity(0.15), location: 0.35),
.init(color: .black.opacity(0.78), location: 1)
],
startPoint: .top,
endPoint: .bottom
)
)
// Content: cover on left, info stacked on right
HStack(alignment: .bottom, spacing: 14) {
AsyncCoverImage(url: item.book.cover)
.frame(width: 96, height: 138)
.clipShape(RoundedRectangle(cornerRadius: 10))
.shadow(color: .black.opacity(0.55), radius: 12, y: 6)
.bookCoverZoomSource(slug: item.book.slug)
VStack(alignment: .leading, spacing: 6) {
// Progress indicator
if item.book.totalChapters > 0 {
let pct = min(1.0, Double(item.chapter) / Double(item.book.totalChapters))
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.2))
Capsule().fill(Color.amber.opacity(0.85))
.frame(width: geo.size.width * pct)
}
}
.frame(height: 3)
.frame(maxWidth: 140)
Text("\(Int(pct * 100))% complete")
.font(.caption2)
.foregroundStyle(.white.opacity(0.55))
}
Text(item.book.title)
.font(.headline)
.foregroundStyle(.white)
.lineLimit(2)
Text(item.book.author)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.65))
.lineLimit(1)
Spacer(minLength: 8)
HStack(spacing: 6) {
Image(systemName: "play.fill")
.font(.caption.bold())
Text("Continue Ch.\(item.chapter)")
.font(.subheadline.weight(.semibold))
}
.foregroundStyle(.black.opacity(0.85))
.padding(.horizontal, 14)
.padding(.vertical, 9)
.background(Capsule().fill(Color.amber))
}
Spacer(minLength: 0)
}
.padding(.horizontal, 16)
.padding(.bottom, 18)
}
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.25), radius: 14, y: 5)
}
.buttonStyle(.plain)
}
}
// MARK: - Shelf header
private struct ShelfHeader: View {
@@ -198,7 +100,7 @@ private struct ShelfHeader: View {
}
}
// MARK: - Horizontal shelf: continue reading card
// MARK: - Horizontal shelf: continue reading card (Apple Books style)
private struct ContinueReadingCard: View {
let item: ContinueReadingItem
@@ -209,34 +111,54 @@ private struct ContinueReadingCard: View {
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ZStack(alignment: .bottomTrailing) {
VStack(alignment: .leading, spacing: 8) {
// Cover
ZStack(alignment: .bottomLeading) {
AsyncCoverImage(url: item.book.cover)
.frame(width: 110, height: 158)
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(width: 130, height: 188)
.clipShape(RoundedRectangle(cornerRadius: 10))
.shadow(color: .black.opacity(0.18), radius: 6, y: 3)
.bookCoverZoomSource(slug: item.book.slug)
// Progress arc ring + chapter badge
ZStack {
Circle()
.stroke(Color.white.opacity(0.18), lineWidth: 2.5)
Circle()
.trim(from: 0, to: progressFraction)
.stroke(Color.amber, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
.rotationEffect(.degrees(-90))
Text("Ch.\(item.chapter)")
// "Continue" pill badge at bottom-left
HStack(spacing: 4) {
Image(systemName: "play.fill")
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.white)
.minimumScaleFactor(0.6)
Text("Ch.\(item.chapter)")
.font(.system(size: 10, weight: .bold))
}
.frame(width: 36, height: 36)
.background(.ultraThinMaterial, in: Circle())
.padding(5)
.foregroundStyle(.black.opacity(0.85))
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(Capsule().fill(Color.amber))
.padding(8)
}
// Title
Text(item.book.title)
.font(.caption.bold())
.lineLimit(2)
.frame(width: 110, alignment: .leading)
.frame(width: 130, alignment: .leading)
.foregroundStyle(.primary)
// Progress bar
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule()
.fill(Color.secondary.opacity(0.2))
Capsule()
.fill(Color.amber.opacity(0.85))
.frame(width: geo.size.width * progressFraction)
}
}
.frame(width: 130, height: 3)
// Percent label
Text("\(Int(progressFraction * 100))% complete")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(width: 130)
}
}

View File

@@ -79,6 +79,7 @@ struct ProfileView: View {
.task {
await vm.loadSessions()
}
.sheet(isPresented: $showChangePassword) {
ChangePasswordView()
}

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"