import SwiftUI // MARK: - BookDetailView // Displays book hero (blurred cover bg + cover art + title), meta stats, // expandable summary, CTA buttons, chapters row (→ sheet), and bottom save toggle. // Matches the web UI at ui/src/routes/books/[slug]/+page.svelte. struct BookDetailView: View { let slug: String @State private var vm: BookDetailViewModel @State private var showChapters = false @State private var summaryExpanded = false @EnvironmentObject private var networkMonitor: NetworkMonitor @EnvironmentObject private var authStore: AuthStore init(slug: String) { self.slug = slug _vm = State(initialValue: BookDetailViewModel(slug: slug)) } var body: some View { VStack(spacing: 0) { OfflineBanner() Group { if vm.isLoading && vm.book == nil { loadingState } else if let book = vm.book { content(book: book) } else if vm.error != nil { errorState } } } .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) .navigationBarTitleDisplayMode(.inline) .appNavigationDestination() .toolbar { toolbarContent } .task { guard networkMonitor.isConnected else { return } await vm.load() } .errorAlert($vm.error) .sheet(isPresented: $showChapters) { BookChaptersSheet( slug: slug, chapters: vm.chapters, lastChapter: vm.lastChapter ) } } // MARK: - Main content private func content(book: Book) -> some View { ScrollView { VStack(alignment: .leading, spacing: 0) { heroSection(book: book) statsRow(book: book) Divider() .background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) .padding(.horizontal, 16) summarySection(book: book) Divider() .background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) .padding(.horizontal, 16) ctaButtons Divider() .background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) chaptersRow Divider() .background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) Color.clear.frame(height: 120) } } .ignoresSafeArea(edges: .top) } // MARK: - Hero private func heroSection(book: Book) -> some View { ZStack(alignment: .bottom) { // Blurred cover background AsyncCoverImage(url: book.cover, isBackground: true) .frame(maxWidth: .infinity) .frame(height: 340) .blur(radius: 28) .clipped() .overlay( LinearGradient( colors: [ Color.black.opacity(0.2), Color.black.opacity(0.72), ], startPoint: .top, endPoint: .bottom ) ) VStack(spacing: 16) { // Cover art AsyncCoverImage(url: book.cover) .frame(width: 130, height: 188) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .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: 5) { Text(book.title) .font(.title3.bold()) .foregroundStyle(.white) .multilineTextAlignment(.center) .lineLimit(3) .padding(.horizontal, 24) if !book.author.isEmpty { Text(book.author) .font(.subheadline) .foregroundStyle(.white.opacity(0.7)) } } // Status badge + genre chips VStack(spacing: 8) { if !book.status.isEmpty { BookStatusBadge(status: book.status) } if !book.genres.isEmpty { HStack(spacing: 6) { ForEach(book.genres.prefix(3), id: \.self) { genre in Text(genre) .font(.caption2.bold()) .padding(.horizontal, 8) .padding(.vertical, 4) .background(.ultraThinMaterial, in: Capsule()) .foregroundStyle(.white.opacity(0.9)) } } } } // "Not in library" badge if !vm.inLib { HStack(spacing: 6) { Image(systemName: "icloud.and.arrow.down") .font(.caption2) Text("Not in library") .font(.caption2) } .foregroundStyle(.secondary) .padding(.horizontal, 10) .padding(.vertical, 5) .background(.regularMaterial, in: Capsule()) } } .padding(.horizontal) .padding(.bottom, 28) } .frame(minHeight: 340) } // MARK: - Stats row private func statsRow(book: Book) -> some View { HStack(spacing: 0) { BookMetaStat( value: "\(vm.chapters.isEmpty ? book.totalChapters : vm.chapters.count)", label: "Chapters", icon: "doc.text" ) Divider().frame(height: 36) BookMetaStat( value: book.status.isEmpty ? "—" : book.status.capitalized, label: "Status", icon: "flag" ) if book.ranking > 0 { Divider().frame(height: 36) BookMetaStat(value: "#\(book.ranking)", label: "Rank", icon: "chart.bar.fill") } } .padding(.vertical, 16) .frame(maxWidth: .infinity) .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) } // MARK: - Summary private func summarySection(book: Book) -> some View { VStack(alignment: .leading, spacing: 8) { Text("About") .font(.headline) .padding(.horizontal, 16) if book.summary.isEmpty { Text("No description available.") .font(.subheadline) .foregroundStyle(.secondary) .padding(.horizontal, 16) } else { Text(book.summary) .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(summaryExpanded ? nil : 4) .animation(.spring(response: 0.3, dampingFraction: 0.7), value: summaryExpanded) .padding(.horizontal, 16) if book.summary.count > 200 { Button { UIImpactFeedbackGenerator(style: .light).impactOccurred() withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { summaryExpanded.toggle() } } label: { Text(summaryExpanded ? "Less" : "More") .font(.caption.bold()) .foregroundStyle(Color.amber) } .buttonStyle(.plain) .frame(minWidth: 44, minHeight: 44) .padding(.horizontal, 16) } } } .padding(.vertical, 16) .frame(maxWidth: .infinity, alignment: .leading) } // MARK: - CTA buttons private var ctaButtons: some View { HStack(spacing: 10) { if let last = vm.lastChapter, last > 0 { // Continue reading NavigationLink(value: NavDestination.chapter(slug, last)) { Label("Continue Ch.\(last)", systemImage: "play.fill") .font(.subheadline.bold()) .frame(maxWidth: .infinity) .frame(height: 44) .background(Color.amber) .foregroundStyle(Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1))) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } .buttonStyle(.plain) .simultaneousGesture(TapGesture().onEnded { UIImpactFeedbackGenerator(style: .medium).impactOccurred() }) // Start from ch.1 NavigationLink(value: NavDestination.chapter(slug, 1)) { Label("Ch.1", systemImage: "arrow.counterclockwise") .font(.subheadline.bold()) .frame(height: 44) .padding(.horizontal, 16) .background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) .foregroundStyle(.primary) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } .buttonStyle(.plain) .simultaneousGesture(TapGesture().onEnded { UIImpactFeedbackGenerator(style: .light).impactOccurred() }) } else { // Start reading NavigationLink(value: NavDestination.chapter(slug, 1)) { Label(vm.inLib ? "Start Reading" : "Preview Ch.1", systemImage: "book.fill") .font(.subheadline.bold()) .frame(maxWidth: .infinity) .frame(height: 44) .background(vm.chapters.isEmpty ? Color.amber.opacity(0.4) : Color.amber) .foregroundStyle(Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1))) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } .buttonStyle(.plain) .disabled(vm.chapters.isEmpty) .simultaneousGesture(TapGesture().onEnded { UIImpactFeedbackGenerator(style: .medium).impactOccurred() }) } } .padding(.horizontal, 16) .padding(.vertical, 16) } // MARK: - Chapters row private var chaptersRow: some View { Button { UIImpactFeedbackGenerator(style: .light).impactOccurred() showChapters = true } label: { HStack(spacing: 12) { Image(systemName: "list.number") .font(.subheadline.weight(.semibold)) .foregroundStyle(Color.amber) .frame(width: 28) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { Text("Chapters") .font(.subheadline.weight(.semibold)) .foregroundStyle(.primary) let count = vm.chapters.count if let last = vm.lastChapter, last > 0, count > 0 { Text("Reading Ch.\(last) of \(count)") .font(.caption) .foregroundStyle(.secondary) } else if count > 0 { Text("\(count) chapter\(count == 1 ? "" : "s")") .font(.caption) .foregroundStyle(.secondary) } else if vm.isLoading { Text("Loading…") .font(.caption) .foregroundStyle(.secondary) } } Spacer() Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) .foregroundStyle(.tertiary) } .padding(.horizontal, 16) .padding(.vertical, 14) .frame(minHeight: 44) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel("Chapters list") } // MARK: - Toolbar @ToolbarContentBuilder private var toolbarContent: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { Button { UIImpactFeedbackGenerator(style: .light).impactOccurred() Task { await vm.toggleSaved() } } label: { Image(systemName: vm.saved ? "bookmark.fill" : "bookmark") .foregroundStyle(vm.saved ? Color.amber : .primary) .contentTransition(.symbolEffect(.replace.downUp)) } .disabled(vm.isSaving) .accessibilityLabel(vm.saved ? "Remove from library" : "Save to library") } } // MARK: - Loading / Error states private var loadingState: some View { VStack { Spacer() ProgressView() .tint(Color.amber) .scaleEffect(1.4) Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) } private var errorState: some View { VStack { Spacer() EmptyStateView( icon: "wifi.slash", title: "Couldn't load book", message: vm.error ?? "Something went wrong.", ctaLabel: "Retry", ctaAction: { Task { guard networkMonitor.isConnected else { return } await vm.load() } } ) Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) } } // MARK: - BookChaptersSheet // Shows all chapters in groups of 100 with a searchable list and right-edge jump bar. struct BookChaptersSheet: View { let slug: String let chapters: [ChapterIndex] let lastChapter: Int? @Environment(\.dismiss) private var dismiss @State private var searchText = "" private var filtered: [ChapterIndex] { guard !searchText.isEmpty else { return chapters } let q = searchText.lowercased() return chapters.filter { "\($0.number)".contains(q) || $0.title.lowercased().contains(q) } } /// Chapters in blocks of 100, or a flat "Results" group when searching. private var groups: [(label: String, chapters: [ChapterIndex])] { guard searchText.isEmpty else { return filtered.isEmpty ? [] : [("Results", filtered)] } guard !filtered.isEmpty else { return [] } let blockSize = 100 let minN = filtered.map(\.number).min() ?? 1 let maxN = filtered.map(\.number).max() ?? 1 let firstBlock = ((minN - 1) / blockSize) * blockSize + 1 var result: [(label: String, chapters: [ChapterIndex])] = [] var blockStart = firstBlock while blockStart <= maxN { let blockEnd = blockStart + blockSize - 1 let slice = filtered.filter { $0.number >= blockStart && $0.number <= blockEnd } if !slice.isEmpty { result.append(("\(blockStart)–\(blockEnd)", slice)) } blockStart += blockSize } return result } @State private var activeBlock: String? var body: some View { NavigationStack { ZStack(alignment: .trailing) { List { ForEach(groups, id: \.label) { group in Section { ForEach(group.chapters, id: \.number) { ch in ChapterListRow( chapter: ch, slug: slug, isCurrent: ch.number == lastChapter ) .id(ch.number) } } header: { if searchText.isEmpty { Text(group.label) .font(.caption.bold()) .foregroundStyle(.secondary) .id("header_\(group.label)") } } } if chapters.isEmpty { Section { ProgressView() .frame(maxWidth: .infinity) .padding(.vertical, 24) .listRowBackground(Color.clear) } } } .listStyle(.plain) .scrollContentBackground(.hidden) .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) .searchable( text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Chapter number or title" ) .scrollPosition(id: $activeBlock, anchor: .top) .appNavigationDestination() // Jump bar (hidden while searching) if searchText.isEmpty && groups.count > 1 { ChapterJumpBar( labels: groups.map(\.label), currentChapter: lastChapter ?? 0, groups: groups ) { label in withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { activeBlock = label } } .padding(.trailing, 4) } } .navigationTitle("Chapters (\(filtered.count))") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } .fontWeight(.semibold) .foregroundStyle(Color.amber) } } .onAppear { // Scroll to current chapter's block on open if let block = groups.first(where: { g in g.chapters.contains(where: { $0.number == (lastChapter ?? 0) }) }) { activeBlock = block.label } } } .presentationDetents([.large]) .presentationDragIndicator(.visible) } } // MARK: - ChapterListRow private struct ChapterListRow: View { let chapter: ChapterIndex let slug: String let isCurrent: Bool private var displayTitle: String { let pattern = #"\s*[-–]\s*\w+\s+\d{1,2}\s+\d{4}\s*$"# let stripped = (try? NSRegularExpression(pattern: pattern))? .stringByReplacingMatches( in: chapter.title, range: NSRange(chapter.title.startIndex..., in: chapter.title), withTemplate: "" ).trimmingCharacters(in: .whitespaces) ?? chapter.title if stripped.isEmpty || stripped == "Chapter \(chapter.number)" { return "Chapter \(chapter.number)" } return stripped } var body: some View { NavigationLink(value: NavDestination.chapter(slug, chapter.number)) { HStack(spacing: 14) { // Number badge ZStack { Circle() .fill(isCurrent ? Color.amber : Color(.systemGray5)) .frame(width: 40, height: 40) Text("\(chapter.number)") .font(.caption.bold().monospacedDigit()) .foregroundStyle(isCurrent ? .white : .secondary) .minimumScaleFactor(0.6) .frame(width: 40, height: 40) } VStack(alignment: .leading, spacing: 3) { Text(displayTitle) .font(.subheadline.weight(isCurrent ? .semibold : .regular)) .foregroundStyle(isCurrent ? Color.amber : .primary) .lineLimit(1) if isCurrent { Label("Reading", systemImage: "bookmark.fill") .font(.caption2) .foregroundStyle(Color.amber) } else if !chapter.dateLabel.isEmpty { Text(chapter.dateLabel) .font(.caption2) .foregroundStyle(.tertiary) } } Spacer(minLength: 4) } .padding(.vertical, 6) .contentShape(Rectangle()) } .listRowBackground(isCurrent ? Color.amber.opacity(0.08) : Color.clear) .listRowSeparatorTint(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) } } // MARK: - ChapterJumpBar private struct ChapterJumpBar: View { let labels: [String] let currentChapter: Int let groups: [(label: String, chapters: [ChapterIndex])] let onSelect: (String) -> Void private func shortLabel(_ full: String) -> String { full.components(separatedBy: "–").first ?? full } private var currentBlock: String? { groups.first(where: { g in g.chapters.contains(where: { $0.number == currentChapter }) })?.label } var body: some View { VStack(spacing: 0) { ForEach(labels, id: \.self) { label in let isCurrent = label == currentBlock Text(shortLabel(label)) .font(.system(size: 10, weight: isCurrent ? .bold : .regular)) .foregroundStyle(isCurrent ? Color.amber : Color.secondary) .frame(width: 28, height: 28) .contentShape(Rectangle()) .onTapGesture { onSelect(label) } } } .padding(.vertical, 6) .background( Capsule() .fill(.ultraThinMaterial) .shadow(color: .black.opacity(0.15), radius: 4) ) .gesture( DragGesture(minimumDistance: 0, coordinateSpace: .local) .onChanged { value in let itemHeight: CGFloat = 28 let index = Int(value.location.y / itemHeight) let clamped = max(0, min(labels.count - 1, index)) onSelect(labels[clamped]) } ) } } // MARK: - BookStatusBadge private struct BookStatusBadge: View { let status: String private var color: Color { switch status.lowercased() { case "ongoing", "active": return .green case "completed": return .blue case "hiatus": return .orange default: return .secondary } } var body: some View { HStack(spacing: 4) { Circle().fill(color).frame(width: 6, height: 6) Text(status.capitalized) .font(.caption.weight(.medium)) .foregroundStyle(color) } .padding(.horizontal, 10) .padding(.vertical, 4) .background(color.opacity(0.12), in: Capsule()) } } // MARK: - BookMetaStat private struct BookMetaStat: View { let value: String let label: String let icon: String var body: some View { VStack(spacing: 4) { Image(systemName: icon) .font(.caption) .foregroundStyle(Color.amber) Text(value) .font(.subheadline.bold()) .lineLimit(1) .minimumScaleFactor(0.7) Text(label) .font(.caption2) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) } }