diff --git a/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift b/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift index ad00a3c..d12bb8f 100644 --- a/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift +++ b/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift @@ -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.. 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) diff --git a/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift b/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift index 34e086e..5211243 100644 --- a/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift +++ b/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift @@ -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) diff --git a/ios/LibNovel/LibNovel/Views/Home/HomeView.swift b/ios/LibNovel/LibNovel/Views/Home/HomeView.swift index 5454e9a..3f72dfe 100644 --- a/ios/LibNovel/LibNovel/Views/Home/HomeView.swift +++ b/ios/LibNovel/LibNovel/Views/Home/HomeView.swift @@ -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) } } diff --git a/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift b/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift index f429efe..bb768e4 100644 --- a/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift +++ b/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift @@ -79,6 +79,7 @@ struct ProfileView: View { .task { await vm.loadSessions() } + .sheet(isPresented: $showChangePassword) { ChangePasswordView() } diff --git a/ui/src/routes/books/[slug]/+page.svelte b/ui/src/routes/books/[slug]/+page.svelte index f1e4d8c..7de26d6 100644 --- a/ui/src/routes/books/[slug]/+page.svelte +++ b/ui/src/routes/books/[slug]/+page.svelte @@ -1,6 +1,4 @@