import SwiftUI // MARK: - BrowseView // "Discover" tab: curated horizontal shelves (Trending, New, Updated, Ranking) // plus a genre picker sheet. Mirrors the web UI's serendipitous browse experience. struct BrowseView: View { @State private var vm = BrowseViewModel() @State private var showGenreSheet = false @EnvironmentObject private var networkMonitor: NetworkMonitor var body: some View { NavigationStack { VStack(spacing: 0) { OfflineBanner() Group { if vm.isLoading && vm.trending.isEmpty { loadingState } else if let err = vm.error, vm.trending.isEmpty { errorState(message: err) } else { shelvesContent } } } .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) .navigationTitle("Discover") .navigationBarTitleDisplayMode(.large) .appNavigationDestination() .task { guard networkMonitor.isConnected else { return } if vm.trending.isEmpty { await vm.loadShelves() } } .refreshable { await vm.loadShelves() } .errorAlert($vm.error) .sheet(isPresented: $showGenreSheet) { GenrePickerSheet() } } } // MARK: - Shelves content private var shelvesContent: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 32) { // Trending Now if !vm.trending.isEmpty { BrowseShelf( title: "Trending Now", novels: vm.trending, destination: NavDestination.browseCategory( sort: "popular", genre: "all", status: "all", title: "Trending Now" ) ) } // New Releases if !vm.newReleases.isEmpty { BrowseShelf( title: "New Releases", novels: vm.newReleases, destination: NavDestination.browseCategory( sort: "new", genre: "all", status: "all", title: "New Releases" ) ) } // Recently Updated if !vm.recentlyUpdated.isEmpty { BrowseShelf( title: "Recently Updated", novels: vm.recentlyUpdated, destination: NavDestination.browseCategory( sort: "update", genre: "all", status: "all", title: "Recently Updated" ) ) } // Rankings (list-style shelf) if !vm.ranking.isEmpty { BrowseShelf( title: "Rankings", novels: vm.ranking, destination: NavDestination.browseCategory( sort: "rank", genre: "all", status: "all", title: "Rankings" ), showRank: true ) } // Browse by Genre CategoriesRow { showGenreSheet = true } .padding(.horizontal, 16) Color.clear.frame(height: 120) } .padding(.top, 8) } } // MARK: - Loading / error states private var loadingState: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 32) { ForEach(0..<3, id: \.self) { _ in BrowseShelfSkeleton() } } .padding(.top, 8) } } private func errorState(message: String) -> some View { VStack(spacing: 16) { Spacer() EmptyStateView( icon: "wifi.slash", title: "Couldn't load", message: message, ctaLabel: "Retry", ctaAction: { Task { await vm.loadShelves() } } ) Spacer() } } } // MARK: - BrowseShelf // Amber-accented header + horizontal card scroll + "See All" link. struct BrowseShelf: View { let title: String let novels: [BrowseNovel] let destination: NavDestination var showRank: Bool = false var body: some View { VStack(alignment: .leading, spacing: 12) { // Header row HStack(spacing: 10) { RoundedRectangle(cornerRadius: 2, style: .continuous) .fill(Color.amber) .frame(width: 3, height: 18) Text(title) .font(.title3.bold()) Spacer() NavigationLink(value: destination) { HStack(spacing: 4) { Text("See All") .font(.subheadline) Image(systemName: "chevron.right") .font(.caption.bold()) } .foregroundStyle(Color.amber) } .buttonStyle(.plain) } .padding(.horizontal, 16) // Horizontal scroll ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: 12) { ForEach(novels) { novel in NavigationLink(value: NavDestination.book(novel.slug)) { BrowseShelfCard(novel: novel, showRank: showRank) } .buttonStyle(.plain) } } .padding(.horizontal, 16) .padding(.vertical, 4) } } } } // MARK: - BrowseShelfCard struct BrowseShelfCard: View { let novel: BrowseNovel var showRank: Bool = false var body: some View { VStack(alignment: .leading, spacing: 0) { ZStack(alignment: .topLeading) { AsyncCoverImage(url: novel.cover) .frame(width: 120, height: 173) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .bookCoverZoomSource(slug: novel.slug) if showRank && !novel.rank.isEmpty { Text(novel.rank) .font(.caption2.bold()) .foregroundStyle(Color.amber) .padding(.horizontal, 6) .padding(.vertical, 3) .background(.ultraThinMaterial, in: Capsule()) .padding(6) } else if !novel.rank.isEmpty { Text(novel.rank) .font(.caption2.bold()) .padding(.horizontal, 6) .padding(.vertical, 3) .background(.ultraThinMaterial, in: Capsule()) .padding(6) } } VStack(alignment: .leading, spacing: 3) { Text(novel.title) .font(.caption.bold()) .lineLimit(2) .multilineTextAlignment(.leading) .frame(width: 120, alignment: .leading) if !novel.author.isEmpty { Text(novel.author) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) .frame(width: 120, alignment: .leading) } else if !novel.chapters.isEmpty { Text(novel.chapters) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) .frame(width: 120, alignment: .leading) } } .padding(.horizontal, 6) .padding(.vertical, 8) } .frame(width: 132) .background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .shadow(color: .black.opacity(0.12), radius: 6, x: 0, y: 2) } } // MARK: - BrowseShelfSkeleton struct BrowseShelfSkeleton: View { var body: some View { VStack(alignment: .leading, spacing: 12) { // Header skeleton HStack(spacing: 10) { RoundedRectangle(cornerRadius: 2) .fill(Color.amber.opacity(0.3)) .frame(width: 3, height: 18) RoundedRectangle(cornerRadius: 6) .fill(Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1))) .frame(width: 140, height: 20) Spacer() } .padding(.horizontal, 16) // Cards skeleton ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(0..<5, id: \.self) { _ in RoundedRectangle(cornerRadius: 14) .fill(Color(uiColor: UIColor(red: 0.18, green: 0.18, blue: 0.20, alpha: 1))) .frame(width: 132, height: 220) } } .padding(.horizontal, 16) } } } } // MARK: - CategoriesRow struct CategoriesRow: View { let onTap: () -> Void var body: some View { Button(action: { UIImpactFeedbackGenerator(style: .light).impactOccurred() onTap() }) { HStack(spacing: 14) { ZStack { RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(Color.amber.opacity(0.15)) .frame(width: 44, height: 44) Image(systemName: "square.grid.2x2") .font(.system(size: 20, weight: .medium)) .foregroundStyle(Color.amber) } VStack(alignment: .leading, spacing: 2) { Text("Browse by Genre") .font(.body.weight(.semibold)) .foregroundStyle(.primary) Text("Action, Fantasy, Romance & more") .font(.caption) .foregroundStyle(.secondary) } Spacer() Image(systemName: "chevron.right") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.tertiary) } .padding(14) .background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) } .buttonStyle(.plain) .accessibilityLabel("Browse by Genre") } } // MARK: - GenrePickerSheet struct GenrePickerSheet: View { @Environment(\.dismiss) private var dismiss private let genres: [(label: String, value: String, icon: String)] = [ ("All Novels", "all", "books.vertical.fill"), ("Action", "action", "bolt.fill"), ("Adventure", "adventure", "map.fill"), ("Comedy", "comedy", "face.smiling.fill"), ("Drama", "drama", "theatermasks.fill"), ("Fantasy", "fantasy", "wand.and.stars"), ("Harem", "harem", "person.3.fill"), ("Historical", "historical", "building.columns.fill"), ("Horror", "horror", "moon.fill"), ("Isekai", "isekai", "globe.americas.fill"), ("Martial Arts", "martial-arts", "figure.martial.arts"), ("Mystery", "mystery", "magnifyingglass"), ("Psychological","psychological","brain.head.profile"), ("Romance", "romance", "heart.fill"), ("Sci-Fi", "sci-fi", "sparkles"), ("System", "system", "cpu"), ("Xianxia", "xianxia", "leaf.fill"), ] private let columns = [ GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12) ] var body: some View { NavigationStack { ScrollView { LazyVGrid(columns: columns, spacing: 12) { ForEach(genres, id: \.value) { item in NavigationLink(value: NavDestination.browseCategory( sort: "popular", genre: item.value, status: "all", title: item.label )) { GenreTile(label: item.label, icon: item.icon) } .buttonStyle(.plain) .simultaneousGesture(TapGesture().onEnded { dismiss() }) } } .padding(16) .padding(.bottom, 20) } .navigationTitle("Genres") .navigationBarTitleDisplayMode(.large) .appNavigationDestination() .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } .fontWeight(.semibold) .foregroundStyle(Color.amber) } } } .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) .presentationCornerRadius(20) } } // MARK: - GenreTile private struct GenreTile: View { let label: String let icon: String var body: some View { HStack(spacing: 10) { Image(systemName: icon) .font(.system(size: 16, weight: .medium)) .foregroundStyle(Color.amber) .frame(width: 24) Text(label) .font(.subheadline.weight(.medium)) .foregroundStyle(.primary) .lineLimit(1) Spacer() } .padding(.horizontal, 14) .padding(.vertical, 14) .background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .frame(minHeight: 44) } }