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
- 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
210 lines
7.7 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|