Add iOS app, SvelteKit JSON API endpoints, and Gitea CI workflow
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 0s
CI / Scraper / Test (pull_request) Successful in 11s
CI / UI / Build (pull_request) Failing after 14s
CI / Scraper / Lint (pull_request) Successful in 23s
CI / Scraper / Build (pull_request) Successful in 24s
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 0s
CI / Scraper / Test (pull_request) Successful in 11s
CI / UI / Build (pull_request) Failing after 14s
CI / Scraper / Lint (pull_request) Successful in 23s
CI / Scraper / Build (pull_request) Successful in 24s
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
- iOS SwiftUI app (ios/LibNovel/) targeting iOS 17+, generated via xcodegen - Full feature set: auth, home, library, book detail, chapter reader, browse, audio player, profile - Kingfisher for image loading, swift-markdown-ui for chapter rendering - Base URL: https://v2.libnovel.kalekber.cc - SvelteKit JSON API routes (ui/src/routes/api/) for iOS consumption: auth/login, auth/register, auth/me, auth/logout, auth/change-password, home, library, book/[slug], chapter/[slug]/[n], search, ranking, progress/[slug], presign/audio (updated) - Gitea Actions CI: .gitea/workflows/ios.yaml (build + test on macos-latest) - justfile: ios-gen, ios-build, ios-test recipes
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
// MARK: - Chapter Reader
|
||||
|
||||
struct ChapterReaderView: View {
|
||||
let slug: String
|
||||
let chapterNumber: Int
|
||||
|
||||
@StateObject private var vm: ChapterReaderViewModel
|
||||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||||
@EnvironmentObject var authStore: AuthStore
|
||||
|
||||
init(slug: String, chapterNumber: Int) {
|
||||
self.slug = slug
|
||||
self.chapterNumber = chapterNumber
|
||||
_vm = StateObject(wrappedValue: ChapterReaderViewModel(slug: slug, chapter: chapterNumber))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if vm.isLoading {
|
||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let content = vm.content {
|
||||
readerContent(content)
|
||||
}
|
||||
}
|
||||
.navigationTitle(vm.content.map { "Ch.\($0.chapter.number)" } ?? "")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { audioToolbarButton }
|
||||
.task { await vm.load() }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .audioDidFinishChapter)) { note in
|
||||
if let next = note.userInfo?["next"] as? Int {
|
||||
vm.navigateTo = next
|
||||
}
|
||||
}
|
||||
.navigationDestination(isPresented: Binding(
|
||||
get: { vm.navigateTo != nil },
|
||||
set: { if !$0 { vm.navigateTo = nil } }
|
||||
)) {
|
||||
if let next = vm.navigateTo {
|
||||
ChapterReaderView(slug: slug, chapterNumber: next)
|
||||
}
|
||||
}
|
||||
.alert("Error", isPresented: .constant(vm.error != nil)) {
|
||||
Button("OK") { vm.error = nil }
|
||||
} message: { Text(vm.error ?? "") }
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
@ViewBuilder
|
||||
private func readerContent(_ content: ChapterResponse) -> some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Header
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(content.chapter.title)
|
||||
.font(.title2.bold())
|
||||
if !content.chapter.dateLabel.isEmpty {
|
||||
Text(content.chapter.dateLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
Divider()
|
||||
|
||||
// Chapter body
|
||||
HTMLContentView(html: content.html)
|
||||
.padding(.horizontal)
|
||||
|
||||
Divider()
|
||||
|
||||
// Prev / Next navigation
|
||||
HStack {
|
||||
if let prev = content.prev {
|
||||
NavigationLink(value: NavDestination.chapter(slug, prev)) {
|
||||
Label("Ch.\(prev)", systemImage: "chevron.left")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
Spacer()
|
||||
if let next = content.next {
|
||||
NavigationLink(value: NavDestination.chapter(slug, next)) {
|
||||
Label("Ch.\(next)", systemImage: "chevron.right")
|
||||
.labelStyle(ReverseLabelStyle())
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Audio toolbar button
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var audioToolbarButton: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
vm.toggleAudio(audioPlayer: audioPlayer, settings: authStore.settings)
|
||||
} label: {
|
||||
Image(systemName: audioPlayer.isActive &&
|
||||
audioPlayer.slug == slug &&
|
||||
audioPlayer.chapter == chapterNumber
|
||||
? "speaker.wave.2.fill" : "speaker.wave.2")
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HTML content renderer using WKWebView
|
||||
|
||||
struct HTMLContentView: UIViewRepresentable {
|
||||
let html: String
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let wv = WKWebView()
|
||||
wv.scrollView.isScrollEnabled = false
|
||||
wv.isOpaque = false
|
||||
wv.backgroundColor = .clear
|
||||
return wv
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: WKWebView, context: Context) {
|
||||
let css = """
|
||||
body {
|
||||
font-family: -apple-system, Georgia, serif;
|
||||
font-size: 17px;
|
||||
line-height: 1.7;
|
||||
color: \(UITraitCollection.current.userInterfaceStyle == .dark ? "#e5e5e5" : "#1a1a1a");
|
||||
background: transparent;
|
||||
margin: 0; padding: 0;
|
||||
}
|
||||
p { margin: 0 0 1em 0; }
|
||||
"""
|
||||
let wrapped = "<html><head><style>\(css)</style><meta name='viewport' content='width=device-width, initial-scale=1'></head><body>\(html)</body></html>"
|
||||
uiView.loadHTMLString(wrapped, baseURL: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reverse label style (icon on right)
|
||||
|
||||
struct ReverseLabelStyle: LabelStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
HStack {
|
||||
configuration.title
|
||||
configuration.icon
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user