Some checks failed
CI / Scraper / Test (push) Successful in 10s
CI / UI / Build (push) Failing after 9s
CI / Scraper / Lint (pull_request) Successful in 7s
CI / UI / Build (pull_request) Failing after 7s
CI / UI / Docker Push (push) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Lint (push) Successful in 28s
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 39s
iOS CI / Build (push) Successful in 2m16s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Successful in 5m35s
iOS CI / Test (pull_request) Successful in 5m50s
- Batch-resolve avatar presign URLs server-side in GET /api/comments/[slug]; returns avatarUrls map alongside comments and myVotes - CommentsSection.svelte: show avatar image or initials fallback (24px top-level, 20px replies) next to each comment/reply username - iOS CommentsResponse gains avatarUrls field; CommentsViewModel stores and populates it on load; CommentRow renders AsyncImage with initials fallback - Also includes: comment replies (1-level nesting), delete, sort (Top/New), parent_id schema migration, and AvatarCropModal cropperjs fix
324 lines
9.5 KiB
Swift
324 lines
9.5 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
|
|
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
|
|
case totalChapters = "total_chapters"
|
|
case sourceURL = "source_url"
|
|
case ranking
|
|
case metaUpdated = "meta_updated"
|
|
}
|
|
|
|
// PocketBase returns genres as either a JSON string array or a real array
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
id = try container.decode(String.self, forKey: .id)
|
|
slug = try container.decode(String.self, forKey: .slug)
|
|
title = try container.decode(String.self, forKey: .title)
|
|
author = try container.decode(String.self, forKey: .author)
|
|
cover = try container.decodeIfPresent(String.self, forKey: .cover) ?? ""
|
|
status = try container.decodeIfPresent(String.self, forKey: .status) ?? ""
|
|
totalChapters = try container.decodeIfPresent(Int.self, forKey: .totalChapters) ?? 0
|
|
sourceURL = try container.decodeIfPresent(String.self, forKey: .sourceURL) ?? ""
|
|
ranking = try container.decodeIfPresent(Int.self, forKey: .ranking) ?? 0
|
|
metaUpdated = try container.decodeIfPresent(String.self, forKey: .metaUpdated) ?? ""
|
|
summary = try container.decodeIfPresent(String.self, forKey: .summary) ?? ""
|
|
|
|
// genres is sometimes a JSON-encoded string, sometimes a real array
|
|
if let arr = try? container.decode([String].self, forKey: .genres) {
|
|
genres = arr
|
|
} else if let str = try? container.decode(String.self, forKey: .genres),
|
|
let data = str.data(using: .utf8),
|
|
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
|
genres = arr
|
|
} else {
|
|
genres = []
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - ChapterIndex
|
|
|
|
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 ChapterIndexBrief: Codable, Hashable {
|
|
let number: Int
|
|
let title: String
|
|
}
|
|
|
|
// MARK: - User Settings
|
|
|
|
struct UserSettings: Codable {
|
|
var id: String?
|
|
var autoNext: Bool
|
|
var voice: String
|
|
var speed: Double
|
|
|
|
// Server sends/expects camelCase: { autoNext, voice, speed }
|
|
// (No CodingKeys needed — Swift synthesises the same names by default)
|
|
|
|
static let `default` = UserSettings(id: nil, autoNext: false, voice: "af_bella", speed: 1.0)
|
|
}
|
|
|
|
// MARK: - Reading Display Settings (local only — stored in 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.1, 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 // follows system
|
|
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
|
|
var lineSpacing: CGFloat
|
|
var font: ReaderFont
|
|
var theme: ReaderTheme
|
|
var scrollMode: Bool
|
|
|
|
static let `default` = ReaderSettings(
|
|
fontSize: 17,
|
|
lineSpacing: 1.7,
|
|
font: .system,
|
|
theme: .white,
|
|
scrollMode: false
|
|
)
|
|
|
|
static let userDefaultsKey = "readerSettings"
|
|
|
|
static func load() -> ReaderSettings {
|
|
guard let data = UserDefaults.standard.data(forKey: userDefaultsKey),
|
|
let decoded = try? JSONDecoder().decode(ReaderSettings.self, from: data)
|
|
else { return .default }
|
|
return decoded
|
|
}
|
|
|
|
func save() {
|
|
if let data = try? JSONEncoder().encode(self) {
|
|
UserDefaults.standard.set(data, forKey: ReaderSettings.userDefaultsKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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(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
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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: - Home
|
|
|
|
struct ContinueReadingItem: Identifiable {
|
|
var id: String { book.id }
|
|
let book: Book
|
|
let chapter: Int
|
|
}
|
|
|
|
struct HomeStats: Codable {
|
|
let totalBooks: Int
|
|
let totalChapters: Int
|
|
let booksInProgress: Int
|
|
}
|
|
|
|
// 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
|
|
case userAgent = "user_agent"
|
|
case ip
|
|
case createdAt = "created_at"
|
|
case lastSeen = "last_seen"
|
|
case isCurrent = "is_current"
|
|
}
|
|
}
|
|
|
|
struct PreviewChapter: Codable, Identifiable {
|
|
var id: Int { number }
|
|
let number: Int
|
|
let title: String
|
|
let url: String
|
|
}
|
|
|
|
struct BookBrief: Codable {
|
|
let slug: String
|
|
let title: String
|
|
let cover: String
|
|
}
|
|
|
|
// 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 // empty = top-level; non-empty = reply
|
|
var replies: [BookComment]? // populated client-side from the API response
|
|
|
|
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
|
|
case myVotes = "myVotes"
|
|
case avatarUrls = "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: - Audio
|
|
|
|
enum NextPrefetchStatus {
|
|
case none, prefetching, prefetched, failed
|
|
}
|