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
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ struct ProfileView: View {
|
||||
.task {
|
||||
await vm.loadSessions()
|
||||
}
|
||||
|
||||
.sheet(isPresented: $showChangePassword) {
|
||||
ChangePasswordView()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
←
|
||||
</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"
|
||||
>
|
||||
→
|
||||
</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">· {(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"
|
||||
|
||||
Reference in New Issue
Block a user