feat: add user profile views and library management
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
This commit is contained in:
Admin
2026-03-11 15:45:04 +05:00
parent b5bc6ff3de
commit 3e4b1c0484
21 changed files with 1111 additions and 74 deletions

View File

@@ -19,19 +19,21 @@
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */; };
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F219788AE5ACBD6F240674F5 /* AuthStore.swift */; };
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B17D50389C6C98FC78BDBC /* ProfileView.swift */; };
C3D7A2E15F8B04C9AB163D50 /* AvatarCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */; };
A2F1C3B84E9D71A0BC164F28 /* AccountMenuSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */; };
D5E2A1C96F3B08D0F74C6B50 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */; };
749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39DE056C37FBC5EED8771821 /* BookDetailView.swift */; };
A1C3F2B84D9E72A1BC054F17 /* CommentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */; };
7C74C10317D389121922A5E3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5A776719B77EDDB5E44743B0 /* Assets.xcassets */; };
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */; };
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.swift */; };
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */; };
A1C3F2B84D9E72A1BC054F17 /* CommentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */; };
A2F1C3B84E9D71A0BC164F28 /* AccountMenuSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */; };
A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB13E89E50529E3081533A66 /* AudioPlayerService.swift */; };
AA11BB22CC33DD44EE55FF66 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF67 /* UserProfileView.swift */; };
BB22CC33DD44EE55FF66AA11 /* UserProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB22CC33DD44EE55FF66AA12 /* UserProfileViewModel.swift */; };
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */; };
C3D7A2E15F8B04C9AB163D50 /* AvatarCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */; };
C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB2E843D93461074A89A171 /* HomeViewModel.swift */; };
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 09584EAB68A07B47F876A062 /* Kingfisher */; };
D5E2A1C96F3B08D0F74C6B50 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */; };
E1F564399D1325F6A1B2B84F /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C21107BECA55C07416E0CB8B /* LibraryView.swift */; };
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D83BB88C4306BE7A4F947CB /* Color+App.swift */; };
EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6268D60803940CBD38FB921 /* HomeView.swift */; };
@@ -57,7 +59,6 @@
235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LibNovelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
2D5C115992F1CE2326236765 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = "<group>"; };
39DE056C37FBC5EED8771821 /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = "<group>"; };
B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsView.swift; sourceTree = "<group>"; };
3AB2E843D93461074A89A171 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
4B820081FA4817765A39939A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
4F56C8E2BC3614530B81569D /* LibNovelApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelApp.swift; sourceTree = "<group>"; };
@@ -72,13 +73,16 @@
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = "<group>"; };
9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewModel.swift; sourceTree = "<group>"; };
9D83BB88C4306BE7A4F947CB /* Color+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+App.swift"; sourceTree = "<group>"; };
AA11BB22CC33DD44EE55FF67 /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = "<group>"; };
B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsView.swift; sourceTree = "<group>"; };
B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMenuSheet.swift; sourceTree = "<group>"; };
B4C918833E173D6B44D06955 /* LibNovelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelTests.swift; sourceTree = "<group>"; };
B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
BB22CC33DD44EE55FF66AA12 /* UserProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileViewModel.swift; sourceTree = "<group>"; };
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCropView.swift; sourceTree = "<group>"; };
B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMenuSheet.swift; sourceTree = "<group>"; };
C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
C21107BECA55C07416E0CB8B /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCropView.swift; sourceTree = "<group>"; };
D6268D60803940CBD38FB921 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
DB13E89E50529E3081533A66 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; };
DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViews.swift; sourceTree = "<group>"; };
@@ -138,18 +142,11 @@
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */,
D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */,
B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */,
AA11BB22CC33DD44EE55FF67 /* UserProfileView.swift */,
);
path = Profile;
sourceTree = "<group>";
};
E6A2B4C07F1D38E0A95B3C72 /* Search */ = {
isa = PBXGroup;
children = (
C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */,
);
path = Search;
sourceTree = "<group>";
};
426F7C5465758645B93A1AB1 /* Networking */ = {
isa = PBXGroup;
children = (
@@ -259,6 +256,7 @@
3AB2E843D93461074A89A171 /* HomeViewModel.swift */,
FC338B05EA6DB22900712000 /* LibraryViewModel.swift */,
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */,
BB22CC33DD44EE55FF66AA12 /* UserProfileViewModel.swift */,
);
path = ViewModels;
sourceTree = "<group>";
@@ -272,6 +270,14 @@
path = Services;
sourceTree = "<group>";
};
E6A2B4C07F1D38E0A95B3C72 /* Search */ = {
isa = PBXGroup;
children = (
C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */,
);
path = Search;
sourceTree = "<group>";
};
FA994FD601E79EC811D822A4 /* Library */ = {
isa = PBXGroup;
children = (
@@ -432,6 +438,8 @@
C3D7A2E15F8B04C9AB163D50 /* AvatarCropView.swift in Sources */,
A2F1C3B84E9D71A0BC164F28 /* AccountMenuSheet.swift in Sources */,
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */,
AA11BB22CC33DD44EE55FF66 /* UserProfileView.swift in Sources */,
BB22CC33DD44EE55FF66AA11 /* UserProfileViewModel.swift in Sources */,
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */,
D5E2A1C96F3B08D0F74C6B50 /* SearchView.swift in Sources */,
41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */,
@@ -580,11 +588,11 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = GHZXC6FVMU;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = GHZXC6FVMU;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
@@ -597,6 +605,7 @@
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel;
PROVISIONING_PROFILE = "af592c3a-f60b-4ac1-a14f-30b8a206017f";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LibNovel Distribution";
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";

View File

@@ -8,6 +8,7 @@ struct RootTabView: View {
@State private var selectedTab: Tab = .home
@State private var showFullPlayer: Bool = false
@State private var showCompactControls: Bool = false
/// Live drag offset while the user is dragging the full player down.
@State private var fullPlayerDragOffset: CGFloat = 0
@@ -16,9 +17,6 @@ struct RootTabView: View {
case home, library, browse, search
}
/// Height of the mini player bar (progress line 2pt + vertical padding 20pt + content ~44pt)
private let miniPlayerBarHeight: CGFloat = AppLayout.miniPlayerBarHeight
var body: some View {
ZStack(alignment: .bottom) {
TabView(selection: $selectedTab) {
@@ -38,20 +36,24 @@ struct RootTabView: View {
.tabItem { Label("Search", systemImage: "magnifyingglass") }
.tag(Tab.search)
}
// Reserve space for the mini-player above the tab bar so scroll content
// never slides beneath it.
.safeAreaInset(edge: .bottom) {
if audioPlayer.isActive {
Color.clear.frame(height: miniPlayerBarHeight)
}
}
// Mini-player pinned above the tab bar (hidden while full player is open)
// Floating circular player button (hidden while full player is open)
if audioPlayer.isActive && !showFullPlayer {
MiniPlayerView(showFullPlayer: $showFullPlayer)
.padding(.bottom, tabBarHeight)
.transition(.move(edge: .bottom).combined(with: .opacity))
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive)
ZStack {
// Compact controls overlay (bottom sheet)
if showCompactControls {
CompactPlayerControls(isPresented: $showCompactControls)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
// Floating button (always on top)
FloatingPlayerButton(
showFullPlayer: $showFullPlayer,
showControls: $showCompactControls
)
}
.transition(.scale.combined(with: .opacity))
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive)
}
// Full player slides up from the bottom as a custom overlay (not a sheet)
@@ -92,14 +94,6 @@ struct RootTabView: View {
}
}
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer)
}
// Approximate safe-area-aware tab bar height
private var tabBarHeight: CGFloat {
let window = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first?.windows.first(where: \.isKeyWindow)
let bottomInset = window?.safeAreaInsets.bottom ?? 0
return 49 + bottomInset // 49pt is the standard iOS tab bar height
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: showCompactControls)
}
}

View File

@@ -5,6 +5,7 @@ import SwiftUI
enum NavDestination: Hashable {
case book(String) // slug
case chapter(String, Int) // slug + chapter number
case userProfile(String) // username
}
// MARK: - View extensions for shared navigation + error alert patterns
@@ -45,6 +46,8 @@ private struct AppNavigationDestinationModifier: ViewModifier {
.navigationTransition(.zoom(sourceID: slug, in: zoomNamespace))
case .chapter(let slug, let n):
ChapterReaderView(slug: slug, chapterNumber: n)
case .userProfile(let username):
UserProfileView(username: username)
}
}
// Expose namespace to child views via environment
@@ -55,6 +58,7 @@ private struct AppNavigationDestinationModifier: ViewModifier {
switch dest {
case .book(let slug): BookDetailView(slug: slug)
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
case .userProfile(let username): UserProfileView(username: username)
}
}
}

View File

@@ -39,11 +39,3 @@ extension String {
return self
}
}
// MARK: - App-wide layout constants
enum AppLayout {
/// Height of the persistent mini-player bar:
/// 12pt vertical padding (top) + 56pt cover height + 12pt vertical padding (bottom) + 12pt horizontal margin.
static let miniPlayerBarHeight: CGFloat = 92
}

View File

@@ -316,6 +316,78 @@ struct CommentsResponse: Decodable {
}
}
// MARK: - User Profile (public)
struct PublicUserProfile: Decodable, Identifiable {
let id: String
let username: String
let avatarUrl: String?
let created: String
let followerCount: Int
let followingCount: Int
let isSubscribed: Bool
let isSelf: Bool
enum CodingKeys: String, CodingKey {
case id, username, created
case avatarUrl = "avatarUrl"
case followerCount = "followerCount"
case followingCount = "followingCount"
case isSubscribed = "isSubscribed"
case isSelf = "isSelf"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
username = try c.decode(String.self, forKey: .username)
avatarUrl = try c.decodeIfPresent(String.self, forKey: .avatarUrl)
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
followerCount = try c.decodeIfPresent(Int.self, forKey: .followerCount) ?? 0
followingCount = try c.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0
isSubscribed = try c.decodeIfPresent(Bool.self, forKey: .isSubscribed) ?? false
isSelf = try c.decodeIfPresent(Bool.self, forKey: .isSelf) ?? false
}
}
// MARK: - Subscription Feed
struct SubscriptionFeedItem: Identifiable, Decodable {
var id: String { book.id + readerUsername }
let book: Book
let readerUsername: String
enum CodingKeys: String, CodingKey {
case book
case readerUsername = "readerUsername"
}
}
// MARK: - Public User Library
struct PublicLibraryItem: Decodable, Identifiable {
var id: String { book.id }
let book: Book
let lastChapter: Int?
let saved: Bool
enum CodingKeys: String, CodingKey {
case book
case lastChapter = "last_chapter"
case saved
}
}
struct PublicUserLibraryResponse: Decodable {
let currentlyReading: [PublicLibraryItem]
let library: [PublicLibraryItem]
enum CodingKeys: String, CodingKey {
case currentlyReading = "currently_reading"
case library
}
}
// MARK: - Audio
enum NextPrefetchStatus {

View File

@@ -189,6 +189,10 @@ actor APIClient {
let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "POST", body: Body(chapter: chapter))
}
func deleteProgress(slug: String) async throws {
let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "DELETE")
}
func audioTime(slug: String, chapter: Int) async throws -> Double? {
struct Response: Decodable { let audioTime: Double?; enum CodingKeys: String, CodingKey { case audioTime = "audio_time" } }
let r: Response = try await fetch("/api/progress/audio-time?slug=\(slug)&chapter=\(chapter)")
@@ -335,6 +339,30 @@ actor APIClient {
return result.avatarURL
}
// MARK: - User Profiles & Subscriptions
func fetchUserProfile(username: String) async throws -> PublicUserProfile {
try await fetch("/api/users/\(username)")
}
@discardableResult
func subscribeUser(username: String) async throws -> Bool {
struct Response: Decodable { let subscribed: Bool }
let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "POST")
return r.subscribed
}
@discardableResult
func unsubscribeUser(username: String) async throws -> Bool {
struct Response: Decodable { let subscribed: Bool }
let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "DELETE")
return r.subscribed
}
func fetchUserLibrary(username: String) async throws -> PublicUserLibraryResponse {
try await fetch("/api/users/\(username)/library")
}
// MARK: - Comments
func fetchComments(slug: String, sort: String = "top") async throws -> CommentsResponse {
@@ -374,11 +402,21 @@ struct HomeDataResponse: Decodable {
let continueReading: [ContinueItem]
let recentlyUpdated: [Book]
let stats: HomeStats
let subscriptionFeed: [SubscriptionFeedItem]
enum CodingKeys: String, CodingKey {
case continueReading = "continue_reading"
case recentlyUpdated = "recently_updated"
case stats
case subscriptionFeed = "subscription_feed"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
continueReading = try c.decodeIfPresent([ContinueItem].self, forKey: .continueReading) ?? []
recentlyUpdated = try c.decodeIfPresent([Book].self, forKey: .recentlyUpdated) ?? []
stats = try c.decode(HomeStats.self, forKey: .stats)
subscriptionFeed = try c.decodeIfPresent([SubscriptionFeedItem].self, forKey: .subscriptionFeed) ?? []
}
}

View File

@@ -23,6 +23,8 @@
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>fetch</string>
<string>processing</string>
</array>
<key>UILaunchScreen</key>
<dict/>

View File

@@ -5,6 +5,7 @@ final class HomeViewModel: ObservableObject {
@Published var continueReading: [ContinueReadingItem] = []
@Published var recentlyUpdated: [Book] = []
@Published var stats: HomeStats?
@Published var subscriptionFeed: [SubscriptionFeedItem] = []
@Published var isLoading = false
@Published var error: String?
@@ -18,6 +19,7 @@ final class HomeViewModel: ObservableObject {
}
recentlyUpdated = data.recentlyUpdated
stats = data.stats
subscriptionFeed = data.subscriptionFeed
} catch {
if !(error is CancellationError) {
self.error = error.localizedDescription

View File

@@ -0,0 +1,87 @@
import Foundation
@MainActor
final class UserProfileViewModel: ObservableObject {
let username: String
@Published var profile: PublicUserProfile?
@Published var currentlyReading: [PublicLibraryItem] = []
@Published var library: [PublicLibraryItem] = []
@Published var isLoading = false
@Published var isTogglingSubscribe = false
@Published var error: String?
init(username: String) {
self.username = username
}
func load() async {
guard !isLoading else { return }
isLoading = true
error = nil
do {
async let profileFetch = APIClient.shared.fetchUserProfile(username: username)
async let libraryFetch = APIClient.shared.fetchUserLibrary(username: username)
let (p, lib) = try await (profileFetch, libraryFetch)
profile = p
currentlyReading = lib.currentlyReading
library = lib.library
} catch let apiError as APIError {
switch apiError {
case .httpError(404, _): error = "User not found."
default: error = apiError.localizedDescription
}
} catch {
if !(error is CancellationError) {
self.error = error.localizedDescription
}
}
isLoading = false
}
func toggleSubscribe() async {
guard let p = profile, !p.isSelf, !isTogglingSubscribe else { return }
isTogglingSubscribe = true
defer { isTogglingSubscribe = false }
do {
if p.isSubscribed {
try await APIClient.shared.unsubscribeUser(username: username)
profile = PublicUserProfile(
id: p.id, username: p.username, avatarUrl: p.avatarUrl,
created: p.created,
followerCount: max(0, p.followerCount - 1),
followingCount: p.followingCount,
isSubscribed: false, isSelf: p.isSelf
)
} else {
try await APIClient.shared.subscribeUser(username: username)
profile = PublicUserProfile(
id: p.id, username: p.username, avatarUrl: p.avatarUrl,
created: p.created,
followerCount: p.followerCount + 1,
followingCount: p.followingCount,
isSubscribed: true, isSelf: p.isSelf
)
}
} catch {
self.error = error.localizedDescription
}
}
}
// MARK: - Convenience memberwise init for PublicUserProfile (used in optimistic updates)
private extension PublicUserProfile {
init(id: String, username: String, avatarUrl: String?, created: String,
followerCount: Int, followingCount: Int, isSubscribed: Bool, isSelf: Bool) {
// Encode then decode to go through the standard Decodable path without duplicating code
var dict: [String: Any] = [
"id": id, "username": username, "created": created,
"followerCount": followerCount, "followingCount": followingCount,
"isSubscribed": isSubscribed, "isSelf": isSelf
]
if let url = avatarUrl { dict["avatarUrl"] = url }
let data = try! JSONSerialization.data(withJSONObject: dict)
self = try! JSONDecoder().decode(PublicUserProfile.self, from: data)
}
}

View File

@@ -33,6 +33,7 @@ struct BookDetailView: View {
.ignoresSafeArea(edges: .top)
}
.navigationBarTitleDisplayMode(.inline)
.appNavigationDestination()
.toolbar { bookmarkButton }
.task { await vm.load() }
.errorAlert($vm.error)

View File

@@ -510,8 +510,13 @@ private struct CommentRow: View {
// Avatar + Username + date
HStack(spacing: 8) {
avatarView
Text(comment.username.isEmpty ? "Anonymous" : comment.username)
.font(isReply ? .caption.weight(.medium) : .subheadline.weight(.medium))
NavigationLink(value: NavDestination.userProfile(comment.username.isEmpty ? "" : comment.username)) {
Text(comment.username.isEmpty ? "Anonymous" : comment.username)
.font(isReply ? .caption.weight(.medium) : .subheadline.weight(.medium))
.foregroundStyle(.primary)
}
.buttonStyle(.plain)
.disabled(comment.username.isEmpty)
Text("·")
.foregroundStyle(.tertiary)
Text(formattedDate(comment.created))

View File

@@ -74,7 +74,7 @@ struct ChapterReaderView: View {
.task(id: currentChapter) { await vm.load() }
.sheet(isPresented: $showSettingsPanel) {
ReaderSettingsPanel(store: readerSettings, isPresented: $showSettingsPanel)
.presentationDetents([.height(380)])
.presentationDetents([.height(480)])
.presentationDragIndicator(.visible)
.presentationCornerRadius(20)
}
@@ -802,6 +802,7 @@ struct ReaderSettingsPanel: View {
@Binding var isPresented: Bool
var body: some View {
ScrollView {
VStack(spacing: 24) {
// Font size row
@@ -903,6 +904,7 @@ struct ReaderSettingsPanel: View {
}
.padding(.horizontal, 24)
.padding(.top, 8)
}
}
private func adjustFontSize(_ delta: CGFloat) {

View File

@@ -20,6 +20,17 @@ struct HomeView: View {
ContinueReadingCard(item: item)
}
.buttonStyle(.plain)
.contextMenu {
ContinueReadingContextMenu(
item: item,
onMarkFinished: {
Task { await markAsFinished(item.book) }
},
onRemove: {
Task { await removeFromLibrary(item.book.slug) }
}
)
}
}
}
.padding(.horizontal)
@@ -45,6 +56,34 @@ struct HomeView: View {
ShelfBookCard(book: book)
}
.buttonStyle(.plain)
.contextMenu {
ShareLink(item: shareURL(for: book)) {
Label("Share", systemImage: "square.and.arrow.up")
}
}
}
}
.padding(.horizontal)
.padding(.bottom, 4)
}
.padding(.bottom, 28)
}
// Subscription feed shelf
if !vm.subscriptionFeed.isEmpty {
ShelfHeader(title: "From People You Follow")
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 14) {
ForEach(vm.subscriptionFeed) { item in
NavigationLink(value: NavDestination.book(item.book.slug)) {
SubscriptionFeedCard(item: item)
}
.buttonStyle(.plain)
.contextMenu {
ShareLink(item: shareURL(for: item.book)) {
Label("Share", systemImage: "square.and.arrow.up")
}
}
}
}
.padding(.horizontal)
@@ -54,7 +93,7 @@ struct HomeView: View {
}
// Empty state
if vm.continueReading.isEmpty && vm.recentlyUpdated.isEmpty && !vm.isLoading {
if vm.continueReading.isEmpty && vm.recentlyUpdated.isEmpty && vm.subscriptionFeed.isEmpty && !vm.isLoading {
EmptyStateView(
icon: "books.vertical",
title: "Your library is empty",
@@ -85,6 +124,30 @@ struct HomeView: View {
}
}
}
private func markAsFinished(_ book: Book) async {
do {
try await APIClient.shared.setProgress(slug: book.slug, chapter: book.totalChapters)
await vm.load() // Refresh home
} catch {
vm.error = error.localizedDescription
}
}
private func removeFromLibrary(_ slug: String) async {
do {
try await APIClient.shared.deleteProgress(slug: slug)
await vm.load() // Refresh home
} catch {
vm.error = error.localizedDescription
}
}
private func shareURL(for book: Book) -> URL {
let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
?? "https://v2.libnovel.kalekber.cc"
return URL(string: "\(baseURL)/books/\(book.slug)")!
}
}
// MARK: - Shelf header
@@ -141,20 +204,20 @@ private struct ContinueReadingCard: View {
.frame(width: 130, alignment: .leading)
.foregroundStyle(.primary)
// Progress bar
// Progress bar show at least a 4pt sliver so early chapters aren't invisible
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule()
.fill(Color.secondary.opacity(0.2))
Capsule()
.fill(Color.amber.opacity(0.85))
.frame(width: geo.size.width * progressFraction)
.frame(width: max(4, geo.size.width * progressFraction))
}
}
.frame(width: 130, height: 3)
// Percent label
Text("\(Int(progressFraction * 100))% complete")
// Percent label floor at 1% so early chapters don't display "0%"
Text("\(max(1, Int(progressFraction * 100)))% complete")
.font(.caption2)
.foregroundStyle(.secondary)
}
@@ -189,6 +252,37 @@ private struct ShelfBookCard: View {
}
}
// MARK: - Horizontal shelf: subscription feed card
private struct SubscriptionFeedCard: View {
let item: SubscriptionFeedItem
var body: some View {
VStack(alignment: .leading, spacing: 6) {
AsyncCoverImage(url: item.book.cover)
.frame(width: 110, height: 158)
.clipShape(RoundedRectangle(cornerRadius: 8))
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
.bookCoverZoomSource(slug: item.book.slug)
Text(item.book.title)
.font(.caption.bold())
.lineLimit(2)
.frame(width: 110, alignment: .leading)
// Tappable "via @username" attribution
NavigationLink(value: NavDestination.userProfile(item.readerUsername)) {
Text("via @\(item.readerUsername)")
.font(.caption2)
.foregroundStyle(Color.amber)
.lineLimit(1)
.frame(width: 110, alignment: .leading)
}
.buttonStyle(.plain)
}
}
}
// MARK: - Stats strip (compact inline)
private struct StatsStrip: View {
@@ -228,3 +322,51 @@ private struct StatPill: View {
.frame(maxWidth: .infinity)
}
}
// MARK: - Context menus
private struct ContinueReadingContextMenu: View {
let item: ContinueReadingItem
let onMarkFinished: () -> Void
let onRemove: () -> Void
private var isFinished: Bool {
guard item.book.totalChapters > 0 else { return false }
return item.chapter >= item.book.totalChapters
}
var body: some View {
Group {
// Share book
ShareLink(item: shareURL) {
Label("Share", systemImage: "square.and.arrow.up")
}
Divider()
// Mark as finished (only show if not already finished)
if !isFinished {
Button {
onMarkFinished()
} label: {
Label("Mark as Finished", systemImage: "checkmark.circle")
}
}
Divider()
// Remove from library (destructive)
Button(role: .destructive) {
onRemove()
} label: {
Label("Remove from Library", systemImage: "trash")
}
}
}
private var shareURL: URL {
let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
?? "https://v2.libnovel.kalekber.cc"
return URL(string: "\(baseURL)/books/\(item.book.slug)")!
}
}

View File

@@ -192,23 +192,39 @@ struct LibraryView: View {
.frame(maxWidth: .infinity)
.padding(.top, 60)
} else {
// 3-column grid
// 2-column grid (matches Discover)
LazyVGrid(
columns: [
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12)
],
spacing: 20
spacing: 16
) {
ForEach(filtered) { item in
NavigationLink(value: NavDestination.book(item.book.slug)) {
LibraryBookCard(item: item)
}
.buttonStyle(.plain)
.contextMenu {
BookContextMenu(
book: item.book,
isFinished: isCompleted(item),
onMarkFinished: {
Task {
await markAsFinished(item.book)
}
},
onRemove: {
Task {
await removeFromLibrary(item.book.slug)
}
}
)
}
}
}
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 24)
}
}
@@ -238,6 +254,24 @@ struct LibraryView: View {
return "No completed books yet."
}
}
private func markAsFinished(_ book: Book) async {
do {
try await APIClient.shared.setProgress(slug: book.slug, chapter: book.totalChapters)
await vm.load() // Refresh library
} catch {
vm.error = error.localizedDescription
}
}
private func removeFromLibrary(_ slug: String) async {
do {
try await APIClient.shared.deleteProgress(slug: slug)
await vm.load() // Refresh library
} catch {
vm.error = error.localizedDescription
}
}
}
// MARK: - Library book card (3-column)
@@ -291,14 +325,14 @@ private struct LibraryBookCard: View {
// Title
Text(item.book.title)
.font(.caption.bold())
.font(.subheadline.bold())
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
// Chapter badge if present
if let ch = item.lastChapter {
Text(isCompleted ? "Finished" : "Ch.\(ch)")
.font(.caption2)
.font(.caption)
.foregroundStyle(isCompleted ? Color.amber : .secondary)
}
}
@@ -323,3 +357,48 @@ private struct ProgressArc: View {
}
}
}
// MARK: - Book context menu
private struct BookContextMenu: View {
let book: Book
let isFinished: Bool
let onMarkFinished: () -> Void
let onRemove: () -> Void
var body: some View {
Group {
// Share book
ShareLink(item: shareURL) {
Label("Share", systemImage: "square.and.arrow.up")
}
Divider()
// Mark as finished (only show if not already finished)
if !isFinished {
Button {
onMarkFinished()
} label: {
Label("Mark as Finished", systemImage: "checkmark.circle")
}
}
Divider()
// Remove from library (destructive)
Button(role: .destructive) {
onRemove()
} label: {
Label("Remove from Library", systemImage: "trash")
}
}
}
private var shareURL: URL {
// Share the book detail page URL
let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
?? "https://v2.libnovel.kalekber.cc"
return URL(string: "\(baseURL)/books/\(book.slug)")!
}
}

View File

@@ -2,9 +2,291 @@ import SwiftUI
import Kingfisher // used directly for blurred background in FullPlayerView
import AVKit // for AVRoutePickerView (AirPlay)
// MARK: - Mini player bar (pinned above tab bar)
// MARK: - Floating circular player button (modern FAB design)
struct MiniPlayerView: View {
// MARK: - Floating circular player button (modern FAB design)
struct FloatingPlayerButton: View {
@Binding var showFullPlayer: Bool
@Binding var showControls: Bool
@EnvironmentObject var audioPlayer: AudioPlayerService
/// Persistent position stored in UserDefaults
@AppStorage("floatingPlayerX") private var savedX: Double = -1
@AppStorage("floatingPlayerY") private var savedY: Double = -1
@State private var position: CGPoint = .zero
@State private var dragOffset: CGSize = .zero
private let buttonSize: CGFloat = 64
private var progressFraction: CGFloat {
guard audioPlayer.duration > 0 else { return 0 }
return CGFloat(audioPlayer.currentTime / audioPlayer.duration)
}
var body: some View {
GeometryReader { geo in
ZStack {
// Circular cover with glassmorphic background
ZStack {
// Progress ring
Circle()
.trim(from: 0, to: progressFraction)
.stroke(Color.amber, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.3), value: audioPlayer.currentTime)
// Cover image
AsyncCoverImage(url: audioPlayer.coverURL)
.frame(width: buttonSize - 8, height: buttonSize - 8)
.clipShape(Circle())
// Play/pause icon overlay (small, centered)
if audioPlayer.status == .ready {
ZStack {
Circle()
.fill(.ultraThinMaterial)
.frame(width: 28, height: 28)
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.white)
.offset(x: audioPlayer.isPlaying ? 0 : 1)
}
} else if audioPlayer.status == .generating {
ZStack {
Circle()
.fill(.ultraThinMaterial)
.frame(width: 28, height: 28)
ProgressView()
.tint(.white)
.scaleEffect(0.7)
}
}
}
.frame(width: buttonSize, height: buttonSize)
.background(
Circle()
.fill(.ultraThinMaterial)
.overlay(
Circle()
.fill(Color.black.opacity(0.2))
)
)
.shadow(color: .black.opacity(0.3), radius: 12, y: 4)
.position(
x: position.x + dragOffset.width,
y: position.y + dragOffset.height
)
.gesture(
DragGesture()
.onChanged { value in
dragOffset = value.translation
}
.onEnded { value in
// Update persistent position
let newX = position.x + value.translation.width
let newY = position.y + value.translation.height
// Clamp to screen bounds with padding
let padding: CGFloat = buttonSize / 2 + 8
position.x = max(padding, min(geo.size.width - padding, newX))
position.y = max(padding, min(geo.size.height - padding, newY))
dragOffset = .zero
// Save to UserDefaults
savedX = position.x
savedY = position.y
}
)
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
showControls.toggle()
}
}
.onLongPressGesture(minimumDuration: 0.5) {
showFullPlayer = true
}
}
.onAppear {
// Initialize position from saved or default to bottom-right
if savedX < 0 || savedY < 0 {
position = CGPoint(
x: geo.size.width - buttonSize / 2 - 20,
y: geo.size.height - buttonSize / 2 - 100
)
} else {
position = CGPoint(x: savedX, y: savedY)
}
}
}
}
}
// MARK: - Compact player controls overlay
struct CompactPlayerControls: View {
@Binding var isPresented: Bool
@EnvironmentObject var audioPlayer: AudioPlayerService
private var progressFraction: CGFloat {
guard audioPlayer.duration > 0 else { return 0 }
return CGFloat(audioPlayer.currentTime / audioPlayer.duration)
}
var body: some View {
VStack(spacing: 0) {
Spacer()
VStack(spacing: 16) {
// Drag handle
Capsule()
.fill(Color.white.opacity(0.3))
.frame(width: 36, height: 4)
.padding(.top, 12)
// Track info
VStack(spacing: 6) {
Text(chapterLabel)
.font(.headline)
.lineLimit(1)
Text(audioPlayer.bookTitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.padding(.horizontal, 20)
// Progress bar with time labels
VStack(spacing: 8) {
HStack(spacing: 12) {
Text(formatTime(audioPlayer.currentTime))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
GeometryReader { geo in
ZStack(alignment: .leading) {
// Track background
RoundedRectangle(cornerRadius: 2)
.fill(.white.opacity(0.2))
.frame(height: 4)
// Progress fill
RoundedRectangle(cornerRadius: 2)
.fill(Color.amber)
.frame(width: geo.size.width * progressFraction, height: 4)
}
}
.frame(height: 4)
Text(formatTime(audioPlayer.duration))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
.padding(.horizontal, 20)
}
// Control buttons
HStack(spacing: 32) {
// Previous chapter
Button {
if let prev = audioPlayer.prevChapter {
NotificationCenter.default.post(
name: .skipToPrevChapter,
object: nil,
userInfo: ["prev": prev]
)
}
} label: {
Image(systemName: "backward.end.fill")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 50, height: 50)
}
.disabled(audioPlayer.prevChapter == nil)
.opacity(audioPlayer.prevChapter == nil ? 0.4 : 1.0)
// Play/pause
Button {
audioPlayer.togglePlayPause()
} label: {
ZStack {
Circle()
.fill(Color.amber)
.frame(width: 60, height: 60)
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(.black)
.offset(x: audioPlayer.isPlaying ? 0 : 2)
}
}
.disabled(audioPlayer.status != .ready)
// Next chapter
Button {
if let next = audioPlayer.nextChapter {
NotificationCenter.default.post(
name: .skipToNextChapter,
object: nil,
userInfo: ["next": next]
)
}
} label: {
Image(systemName: "forward.end.fill")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 50, height: 50)
}
.disabled(audioPlayer.nextChapter == nil)
.opacity(audioPlayer.nextChapter == nil ? 0.4 : 1.0)
}
.padding(.vertical, 8)
// Bottom safe area padding
Color.clear.frame(height: 20)
}
.background(
.ultraThinMaterial,
in: RoundedRectangle(cornerRadius: 24, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(Color.black.opacity(0.3))
)
.padding(.horizontal, 16)
.padding(.bottom, 16)
.shadow(color: .black.opacity(0.4), radius: 20, y: -8)
}
.ignoresSafeArea()
.onTapGesture {
// Tap outside to dismiss
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
isPresented = false
}
}
}
private var chapterLabel: String {
let raw = audioPlayer.chapterTitle.isEmpty
? "Chapter \(audioPlayer.chapter)"
: audioPlayer.chapterTitle
return raw.strippingTrailingDate()
}
private func formatTime(_ seconds: Double) -> String {
guard seconds.isFinite && seconds >= 0 else { return "0:00" }
let mins = Int(seconds) / 60
let secs = Int(seconds) % 60
return String(format: "%d:%02d", mins, secs)
}
}
// MARK: - Legacy mini player (kept for reference, will be removed)
struct MiniPlayerView_Legacy: View {
@Binding var showFullPlayer: Bool
@EnvironmentObject var audioPlayer: AudioPlayerService

View File

@@ -0,0 +1,209 @@
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)
}
}
}

View File

@@ -115,7 +115,6 @@ struct SearchView: View {
LazyVGrid(
columns: [
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12)
],
@@ -145,7 +144,6 @@ struct SearchView: View {
ScrollView {
LazyVGrid(
columns: [
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12)
],
@@ -163,7 +161,7 @@ struct SearchView: View {
}
}
// MARK: - Search novel card (compact 3-column)
// MARK: - Search novel card (compact 2-column)
private struct SearchNovelCard: View {
let novel: BrowseNovel
@@ -178,7 +176,7 @@ private struct SearchNovelCard: View {
.bookCoverZoomSource(slug: novel.slug)
Text(novel.title)
.font(.caption.bold())
.font(.subheadline.bold())
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}