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
418 lines
12 KiB
Swift
418 lines
12 KiB
Swift
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
|
|
}
|