import Foundation // MARK: - BrowseViewModel // Powers both the Discover shelves (BrowseView) and the full paginated grid (BrowseCategoryView). // Uses @Observable (iOS 17+). @Observable @MainActor final class BrowseViewModel { // MARK: - Discover shelves (BrowseView) var trending: [BrowseNovel] = [] var newReleases: [BrowseNovel] = [] var recentlyUpdated: [BrowseNovel] = [] var ranking: [BrowseNovel] = [] // MARK: - Paginated grid (BrowseCategoryView) var novels: [BrowseNovel] = [] var currentPage = 1 var hasNext = false // Filter params (BrowseCategoryView sets these before calling loadFirstPage) var sort: String = "popular" var genre: String = "all" var status: String = "all" // MARK: - UI state var isLoading = false var isLoadingMore = false var error: String? // MARK: - Discover load (fetches multiple shelves in parallel) func loadShelves() async { isLoading = true error = nil do { async let trendingTask = APIClient.shared.browse(page: 1, genre: "all", sort: "popular", status: "all") async let newTask = APIClient.shared.browse(page: 1, genre: "all", sort: "new", status: "all") async let updatedTask = APIClient.shared.browse(page: 1, genre: "all", sort: "update", status: "all") async let rankingTask = APIClient.shared.ranking() let (trendingResp, newResp, updatedResp, rankItems) = try await ( trendingTask, newTask, updatedTask, rankingTask ) trending = Array(trendingResp.novels.prefix(12)) newReleases = Array(newResp.novels.prefix(12)) recentlyUpdated = Array(updatedResp.novels.prefix(12)) ranking = rankItems.prefix(12).map { item in BrowseNovelFromRanking(item) } } catch { if !(error is CancellationError) { self.error = error.localizedDescription } } isLoading = false } // MARK: - Paginated category load func loadFirstPage() async { guard !isLoading else { return } novels = [] currentPage = 1 hasNext = false isLoading = true error = nil do { let resp = try await APIClient.shared.browse( page: 1, genre: genre, sort: sort, status: status) novels = resp.novels currentPage = resp.page hasNext = resp.hasNext } catch { if !(error is CancellationError) { self.error = error.localizedDescription } } isLoading = false } func loadNextPage() async { guard hasNext, !isLoadingMore, !isLoading else { return } isLoadingMore = true let next = currentPage + 1 do { let resp = try await APIClient.shared.browse( page: next, genre: genre, sort: sort, status: status) novels += resp.novels currentPage = resp.page hasNext = resp.hasNext } catch { // Silently ignore — user can scroll again } isLoadingMore = false } // MARK: - Ranking load (for rank sort mode) func loadRanking() async { guard !isLoading else { return } novels = [] hasNext = false isLoading = true error = nil do { let items = try await APIClient.shared.ranking() novels = items.map { BrowseNovelFromRanking($0) } } catch { if !(error is CancellationError) { self.error = error.localizedDescription } } isLoading = false } } // MARK: - RankingItem → BrowseNovel adapter private func BrowseNovelFromRanking(_ item: RankingItem) -> BrowseNovel { // Synthesise a minimal JSON blob so we can decode via the standard init let rankStr = "#\(item.rank)" let dict: [String: Any] = [ "slug": item.slug, "title": item.title, "cover": item.cover, "rank": rankStr, "rating": "", "chapters": "", "url": item.sourceURL, "author": item.author, "status": item.status, "genres": item.genres ] let data = try! JSONSerialization.data(withJSONObject: dict) return try! JSONDecoder.apiDecoder.decode(BrowseNovel.self, from: data) }