Files
libnovel/ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift
Admin 3e4b1c0484
Some checks failed
CI / UI / Build (push) Failing after 11s
CI / Scraper / Lint (pull_request) Failing after 15s
CI / UI / Docker Push (push) Has been skipped
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Failing after 17s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 3m40s
iOS CI / Build (pull_request) Successful in 1m49s
iOS CI / Test (push) Successful in 4m24s
iOS CI / Test (pull_request) Successful in 4m46s
feat: add user profile views and library management
- Add UserProfileView and UserProfileViewModel for iOS
- Implement user library API endpoint (/api/users/[username]/library)
- Add DELETE /api/progress/[slug] endpoint for removing books from library
- Integrate subscription feed in home API
- Update Xcode project with new profile components
2026-03-11 15:45:04 +05:00

210 lines
7.7 KiB
Swift

import SwiftUI
struct UserProfileView: View {
let username: String
@StateObject private var vm: UserProfileViewModel
@EnvironmentObject private var authStore: AuthStore
init(username: String) {
self.username = username
_vm = StateObject(wrappedValue: UserProfileViewModel(username: username))
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
if vm.isLoading && vm.profile == nil {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.top, 60)
} else if let profile = vm.profile {
profileHeader(profile)
.padding(.bottom, 28)
if !vm.currentlyReading.isEmpty {
ShelfHeader(title: "Currently Reading")
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 14) {
ForEach(vm.currentlyReading) { item in
NavigationLink(value: NavDestination.book(item.book.slug)) {
ProfileBookCard(item: item)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
.padding(.bottom, 4)
}
.padding(.bottom, 28)
}
if !vm.library.isEmpty {
ShelfHeader(title: "Library")
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 14) {
ForEach(vm.library) { item in
NavigationLink(value: NavDestination.book(item.book.slug)) {
ProfileBookCard(item: item)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
.padding(.bottom, 4)
}
.padding(.bottom, 28)
}
if vm.currentlyReading.isEmpty && vm.library.isEmpty && !vm.isLoading {
EmptyStateView(
icon: "books.vertical",
title: "No books yet",
message: "\(username) hasn't read anything yet."
)
.frame(maxWidth: .infinity)
.padding(.top, 20)
}
} else if let err = vm.error {
EmptyStateView(icon: "exclamationmark.triangle", title: "Error", message: err)
.frame(maxWidth: .infinity)
.padding(.top, 60)
}
Color.clear.frame(height: 20)
}
}
.navigationTitle("@\(username)")
.navigationBarTitleDisplayMode(.inline)
.task { await vm.load() }
.refreshable { await vm.load() }
.errorAlert($vm.error)
}
// MARK: - Profile header
@ViewBuilder
private func profileHeader(_ profile: PublicUserProfile) -> some View {
VStack(alignment: .center, spacing: 16) {
AvatarThumb(urlString: profile.avatarUrl, size: 80)
VStack(spacing: 4) {
Text("@\(profile.username)")
.font(.title3.bold())
if !profile.created.isEmpty {
Text("Joined \(shortDate(profile.created))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
// Stats row
HStack(spacing: 32) {
VStack(spacing: 2) {
Text("\(profile.followerCount)")
.font(.subheadline.bold().monospacedDigit())
Text("Followers")
.font(.caption2)
.foregroundStyle(.secondary)
}
VStack(spacing: 2) {
Text("\(profile.followingCount)")
.font(.subheadline.bold().monospacedDigit())
Text("Following")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
// Follow button only shown for other users (not self)
if !profile.isSelf && authStore.isAuthenticated {
Button {
Task { await vm.toggleSubscribe() }
} label: {
if vm.isTogglingSubscribe {
ProgressView().controlSize(.small)
.frame(width: 120, height: 34)
} else if profile.isSubscribed {
Label("Following", systemImage: "checkmark")
.font(.subheadline.bold())
.frame(width: 120, height: 34)
} else {
Text("Follow")
.font(.subheadline.bold())
.frame(width: 120, height: 34)
}
}
.buttonStyle(.borderedProminent)
.tint(profile.isSubscribed ? Color(.systemGray4) : .amber)
.disabled(vm.isTogglingSubscribe)
}
}
.frame(maxWidth: .infinity)
.padding(.top, 24)
.padding(.horizontal)
}
private func shortDate(_ iso: String) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ"
if let date = formatter.date(from: iso) {
let out = DateFormatter()
out.dateStyle = .medium
out.timeStyle = .none
return out.string(from: date)
}
return String(iso.prefix(10))
}
}
// MARK: - Shelf header
private struct ShelfHeader: View {
let title: String
var body: some View {
Text(title)
.font(.title3.bold())
.padding(.horizontal)
.padding(.bottom, 10)
}
}
// MARK: - Book card for profile shelves
private struct ProfileBookCard: View {
let item: PublicLibraryItem
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ZStack(alignment: .bottomLeading) {
AsyncCoverImage(url: item.book.cover)
.frame(width: 110, height: 158)
.clipShape(RoundedRectangle(cornerRadius: 8))
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
// Chapter badge (if reading)
if let ch = item.lastChapter, ch > 0 {
Text("Ch.\(ch)")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.black.opacity(0.85))
.padding(.horizontal, 7)
.padding(.vertical, 4)
.background(Capsule().fill(Color.amber))
.padding(6)
}
}
Text(item.book.title)
.font(.caption.bold())
.lineLimit(2)
.frame(width: 110, alignment: .leading)
Text(item.book.author)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.frame(width: 110, alignment: .leading)
}
}
}