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
87 lines
3.2 KiB
Swift
87 lines
3.2 KiB
Swift
import SwiftUI
|
|
import Kingfisher
|
|
|
|
struct LibraryView: View {
|
|
@StateObject private var vm = LibraryViewModel()
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if vm.isLoading && vm.items.isEmpty {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if vm.items.isEmpty {
|
|
EmptyStateView(
|
|
icon: "bookmark",
|
|
title: "No saved books",
|
|
message: "Books you save or start reading will appear here."
|
|
)
|
|
} else {
|
|
ScrollView {
|
|
LazyVGrid(
|
|
columns: [GridItem(.adaptive(minimum: 150), spacing: 12)],
|
|
spacing: 16
|
|
) {
|
|
ForEach(vm.items) { item in
|
|
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
|
LibraryCard(item: item)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Library")
|
|
.navigationDestination(for: NavDestination.self) { dest in
|
|
switch dest {
|
|
case .book(let slug): BookDetailView(slug: slug)
|
|
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
|
}
|
|
}
|
|
.refreshable { await vm.load() }
|
|
.task { await vm.load() }
|
|
.alert("Error", isPresented: .constant(vm.error != nil)) {
|
|
Button("OK") { vm.error = nil }
|
|
} message: { Text(vm.error ?? "") }
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct LibraryCard: View {
|
|
let item: LibraryItem
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
ZStack(alignment: .bottomTrailing) {
|
|
KFImage(URL(string: item.book.cover))
|
|
.resizable()
|
|
.placeholder {
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill(Color(.systemGray5))
|
|
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
|
|
}
|
|
.scaledToFill()
|
|
.frame(height: 200)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
|
|
if let ch = item.lastChapter {
|
|
Text("Ch.\(ch)")
|
|
.font(.caption2.bold())
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 3)
|
|
.background(.ultraThinMaterial, in: Capsule())
|
|
.padding(6)
|
|
}
|
|
}
|
|
Text(item.book.title)
|
|
.font(.caption.bold())
|
|
.lineLimit(2)
|
|
Text(item.book.author)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|