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

- 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:
Admin
2026-03-07 18:17:51 +05:00
parent 1eb70e9b9b
commit f51113a2f8
50 changed files with 4875 additions and 1 deletions

View File

@@ -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
}
}
}