All checks were successful
CI / Scraper / Lint (push) Successful in 10s
CI / Scraper / Test (push) Successful in 14s
Release / Scraper / Test (push) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 18s
Release / UI / Build (push) Successful in 23s
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 32s
Release / Scraper / Docker (push) Successful in 55s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 1m5s
Release / UI / Docker (push) Successful in 1m12s
iOS CI / Build (push) Successful in 4m18s
iOS CI / Build (pull_request) Successful in 4m25s
iOS CI / Test (push) Successful in 8m11s
iOS CI / Test (pull_request) Successful in 8m21s
412 lines
14 KiB
Swift
412 lines
14 KiB
Swift
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)
|
|
}
|
|
}
|