import Foundation import SwiftUI // MARK: - Book struct Book: Identifiable, Codable, Hashable { let id: String let slug: String let title: String let author: String let cover: String // proxied via /api/cover/... let status: String let genres: [String] let summary: String let totalChapters: Int let sourceURL: String let ranking: Int let metaUpdated: String enum CodingKeys: String, CodingKey { case id, slug, title, author, cover, status, genres, summary, ranking case totalChapters = "total_chapters" case sourceURL = "source_url" case metaUpdated = "meta_updated" } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) id = try c.decode(String.self, forKey: .id) slug = try c.decode(String.self, forKey: .slug) title = try c.decode(String.self, forKey: .title) author = try c.decodeIfPresent(String.self, forKey: .author) ?? "" cover = try c.decodeIfPresent(String.self, forKey: .cover) ?? "" status = try c.decodeIfPresent(String.self, forKey: .status) ?? "" totalChapters = try c.decodeIfPresent(Int.self, forKey: .totalChapters) ?? 0 sourceURL = try c.decodeIfPresent(String.self, forKey: .sourceURL) ?? "" ranking = try c.decodeIfPresent(Int.self, forKey: .ranking) ?? 0 metaUpdated = try c.decodeIfPresent(String.self, forKey: .metaUpdated) ?? "" summary = try c.decodeIfPresent(String.self, forKey: .summary) ?? "" // genres can arrive as a JSON-encoded string or a real array if let arr = try? c.decode([String].self, forKey: .genres) { genres = arr } else if let raw = try? c.decode(String.self, forKey: .genres), let data = raw.data(using: .utf8), let arr = try? JSONDecoder().decode([String].self, from: data) { genres = arr } else { genres = [] } } } // MARK: - Chapter index struct ChapterIndex: Identifiable, Codable, Hashable { let id: String let slug: String let number: Int let title: String let dateLabel: String enum CodingKeys: String, CodingKey { case id, slug, number, title case dateLabel = "date_label" } } struct ChapterBrief: Identifiable, Codable, Hashable { var id: Int { number } let number: Int let title: String } // Full chapter response from /api/chapter-text/{slug}/{n} struct ChapterResponse: Decodable { struct BookBrief: Decodable { let slug: String let title: String let cover: String } struct ChapterDetail: Decodable { let number: Int let title: String let dateLabel: String enum CodingKeys: String, CodingKey { case number, title case dateLabel = "date_label" } } let book: BookBrief let chapter: ChapterDetail let chapters: [ChapterBrief] let html: String let text: String let prev: Int? let next: Int? } // MARK: - Ranking struct RankingItem: Codable, Identifiable { var id: String { slug } let rank: Int let slug: String let title: String let author: String let cover: String let status: String let genres: [String] let sourceURL: String enum CodingKeys: String, CodingKey { case rank, slug, title, author, cover, status, genres case sourceURL = "source_url" } } // MARK: - Browse listing struct NovelListing: Codable, Identifiable { var id: String { slug } let slug: String let title: String let author: String? let cover: String? let status: String? let genres: [String]? let rank: Int? let rating: String? let chapters: String? // e.g. "123 chapters" let url: String? let sourceURL: String? enum CodingKeys: String, CodingKey { case slug, title, author, cover, status, genres, rank, rating, chapters, url case sourceURL = "source_url" } } // MARK: - Home struct HomeStats: Codable { let totalBooks: Int let totalChapters: Int let booksInProgress: Int enum CodingKeys: String, CodingKey { case totalBooks = "total_books" case totalChapters = "total_chapters" case booksInProgress = "books_in_progress" } } struct ContinueReadingItem: Identifiable { var id: String { book.id } let book: Book let chapter: Int } struct SubscriptionFeedItem: Identifiable, Decodable { var id: String { book.id + readerUsername } let book: Book let readerUsername: String enum CodingKeys: String, CodingKey { case book case readerUsername = "readerUsername" } } // MARK: - User struct AppUser: Codable, Identifiable { let id: String let username: String let role: String let created: String let avatarURL: String? var isAdmin: Bool { role == "admin" } enum CodingKeys: String, CodingKey { case id, username, role, created case avatarURL = "avatar_url" } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) id = try c.decode(String.self, forKey: .id) username = try c.decode(String.self, forKey: .username) role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user" created = try c.decodeIfPresent(String.self, forKey: .created) ?? "" avatarURL = try c.decodeIfPresent(String.self, forKey: .avatarURL) } init(id: String, username: String, role: String, created: String, avatarURL: String?) { self.id = id self.username = username self.role = role self.created = created self.avatarURL = avatarURL } } // MARK: - User settings struct UserSettings: Codable { var autoNext: Bool var voice: String var speed: Double static let `default` = UserSettings(autoNext: false, voice: "af_bella", speed: 1.0) } // MARK: - Session struct UserSession: Codable, Identifiable { let id: String let userAgent: String let ip: String let createdAt: String let lastSeen: String var isCurrent: Bool enum CodingKeys: String, CodingKey { case id, ip case userAgent = "user_agent" case createdAt = "created_at" case lastSeen = "last_seen" case isCurrent = "is_current" } } // MARK: - Comments struct BookComment: Identifiable, Codable, Hashable { let id: String let slug: String let userId: String let username: String let body: String var upvotes: Int var downvotes: Int let created: String let parentId: String var replies: [BookComment]? enum CodingKeys: String, CodingKey { case id, slug, username, body, upvotes, downvotes, created, replies case userId = "user_id" case parentId = "parent_id" } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) id = try c.decode(String.self, forKey: .id) slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? "" userId = try c.decodeIfPresent(String.self, forKey: .userId) ?? "" username = try c.decodeIfPresent(String.self, forKey: .username) ?? "" body = try c.decodeIfPresent(String.self, forKey: .body) ?? "" upvotes = try c.decodeIfPresent(Int.self, forKey: .upvotes) ?? 0 downvotes = try c.decodeIfPresent(Int.self, forKey: .downvotes) ?? 0 created = try c.decodeIfPresent(String.self, forKey: .created) ?? "" parentId = try c.decodeIfPresent(String.self, forKey: .parentId) ?? "" replies = try c.decodeIfPresent([BookComment].self, forKey: .replies) } } struct CommentsResponse: Decodable { let comments: [BookComment] let myVotes: [String: String] let avatarUrls: [String: String] enum CodingKeys: String, CodingKey { case comments, myVotes, avatarUrls } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) comments = try c.decode([BookComment].self, forKey: .comments) myVotes = try c.decodeIfPresent([String: String].self, forKey: .myVotes) ?? [:] avatarUrls = try c.decodeIfPresent([String: String].self, forKey: .avatarUrls) ?? [:] } } // MARK: - Public user profile struct PublicUserProfile: Decodable, Identifiable { let id: String let username: String let avatarUrl: String? let created: String let followerCount: Int let followingCount: Int let isSubscribed: Bool let isSelf: Bool enum CodingKeys: String, CodingKey { case id, username, created case avatarUrl = "avatarUrl" case followerCount = "followerCount" case followingCount = "followingCount" case isSubscribed = "isSubscribed" case isSelf = "isSelf" } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) id = try c.decode(String.self, forKey: .id) username = try c.decode(String.self, forKey: .username) avatarUrl = try c.decodeIfPresent(String.self, forKey: .avatarUrl) created = try c.decodeIfPresent(String.self, forKey: .created) ?? "" followerCount = try c.decodeIfPresent(Int.self, forKey: .followerCount) ?? 0 followingCount = try c.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0 isSubscribed = try c.decodeIfPresent(Bool.self, forKey: .isSubscribed) ?? false isSelf = try c.decodeIfPresent(Bool.self, forKey: .isSelf) ?? false } } struct PublicLibraryItem: Decodable, Identifiable { var id: String { book.id } let book: Book let lastChapter: Int? let saved: Bool enum CodingKeys: String, CodingKey { case book case lastChapter = "last_chapter" case saved } } struct PublicUserLibraryResponse: Decodable { let currentlyReading: [PublicLibraryItem] let library: [PublicLibraryItem] enum CodingKeys: String, CodingKey { case currentlyReading = "currently_reading" case library } } // MARK: - Reader Settings (local — UserDefaults) enum ReaderTheme: String, CaseIterable, Codable { case white, sepia, night var backgroundColor: Color { switch self { case .white: return Color(.sRGB, white: 1.0, opacity: 1) case .sepia: return Color(red: 0.97, green: 0.93, blue: 0.82) case .night: return Color(red: 0.10, green: 0.10, blue: 0.12) } } var textColor: Color { switch self { case .white: return Color(.sRGB, white: 0.10, opacity: 1) case .sepia: return Color(red: 0.25, green: 0.18, blue: 0.08) case .night: return Color(red: 0.85, green: 0.85, blue: 0.87) } } var colorScheme: ColorScheme? { switch self { case .white: return nil case .sepia: return .light case .night: return .dark } } } enum ReaderFont: String, CaseIterable, Codable { case system = "System" case georgia = "Georgia" case newYork = "New York" var fontName: String? { switch self { case .system: return nil case .georgia: return "Georgia" case .newYork: return "NewYorkMedium-Regular" } } } struct ReaderSettings: Codable, Equatable { var fontSize: CGFloat = 17 var lineSpacing: CGFloat = 1.7 var font: ReaderFont = .system var theme: ReaderTheme = .white var scrollMode: Bool = false private static let key = "v2.readerSettings" static func load() -> ReaderSettings { guard let data = UserDefaults.standard.data(forKey: key), let decoded = try? JSONDecoder().decode(ReaderSettings.self, from: data) else { return ReaderSettings() } return decoded } func save() { if let data = try? JSONEncoder().encode(self) { UserDefaults.standard.set(data, forKey: ReaderSettings.key) } } } // MARK: - Audio prefetch status enum NextPrefetchStatus { case none, prefetching, prefetched, failed }