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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user