From 3e4b1c04849a2a86aae301d1d5f42b9bb97b22b7 Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 11 Mar 2026 15:45:04 +0500 Subject: [PATCH] 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 --- .../LibNovel.xcodeproj/project.pbxproj | 45 +-- ios/LibNovel/LibNovel/App/RootTabView.swift | 42 ++- .../LibNovel/Extensions/NavDestination.swift | 4 + .../LibNovel/Extensions/String+App.swift | 8 - ios/LibNovel/LibNovel/Models/Models.swift | 72 +++++ .../LibNovel/Networking/APIClient.swift | 38 +++ ios/LibNovel/LibNovel/Resources/Info.plist | 2 + .../LibNovel/ViewModels/HomeViewModel.swift | 2 + .../ViewModels/UserProfileViewModel.swift | 87 ++++++ .../Views/BookDetail/BookDetailView.swift | 1 + .../Views/BookDetail/CommentsView.swift | 9 +- .../ChapterReader/ChapterReaderView.swift | 4 +- .../LibNovel/Views/Home/HomeView.swift | 152 +++++++++- .../LibNovel/Views/Library/LibraryView.swift | 89 +++++- .../LibNovel/Views/Player/PlayerViews.swift | 286 +++++++++++++++++- .../Views/Profile/UserProfileView.swift | 209 +++++++++++++ .../LibNovel/Views/Search/SearchView.swift | 6 +- ui/src/lib/server/pocketbase.ts | 41 ++- ui/src/routes/api/home/+server.ts | 23 +- ui/src/routes/api/progress/[slug]/+server.ts | 22 +- .../api/users/[username]/library/+server.ts | 43 +++ 21 files changed, 1111 insertions(+), 74 deletions(-) create mode 100644 ios/LibNovel/LibNovel/ViewModels/UserProfileViewModel.swift create mode 100644 ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift create mode 100644 ui/src/routes/api/users/[username]/library/+server.ts diff --git a/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj b/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj index bb71cd2..a4bced2 100644 --- a/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj +++ b/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj @@ -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 = ""; }; 39DE056C37FBC5EED8771821 /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = ""; }; - B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsView.swift; sourceTree = ""; }; 3AB2E843D93461074A89A171 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; 4B820081FA4817765A39939A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 4F56C8E2BC3614530B81569D /* LibNovelApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelApp.swift; sourceTree = ""; }; @@ -72,13 +73,16 @@ 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; 9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewModel.swift; sourceTree = ""; }; 9D83BB88C4306BE7A4F947CB /* Color+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+App.swift"; sourceTree = ""; }; + AA11BB22CC33DD44EE55FF67 /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = ""; }; + B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsView.swift; sourceTree = ""; }; + B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMenuSheet.swift; sourceTree = ""; }; B4C918833E173D6B44D06955 /* LibNovelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelTests.swift; sourceTree = ""; }; B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; + BB22CC33DD44EE55FF66AA12 /* UserProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileViewModel.swift; sourceTree = ""; }; C0B17D50389C6C98FC78BDBC /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; - D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCropView.swift; sourceTree = ""; }; - B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMenuSheet.swift; sourceTree = ""; }; - C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; C21107BECA55C07416E0CB8B /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; + C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; + D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCropView.swift; sourceTree = ""; }; D6268D60803940CBD38FB921 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; DB13E89E50529E3081533A66 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = ""; }; DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViews.swift; sourceTree = ""; }; @@ -138,18 +142,11 @@ C0B17D50389C6C98FC78BDBC /* ProfileView.swift */, D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */, B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */, + AA11BB22CC33DD44EE55FF67 /* UserProfileView.swift */, ); path = Profile; sourceTree = ""; }; - E6A2B4C07F1D38E0A95B3C72 /* Search */ = { - isa = PBXGroup; - children = ( - C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */, - ); - path = Search; - sourceTree = ""; - }; 426F7C5465758645B93A1AB1 /* Networking */ = { isa = PBXGroup; children = ( @@ -259,6 +256,7 @@ 3AB2E843D93461074A89A171 /* HomeViewModel.swift */, FC338B05EA6DB22900712000 /* LibraryViewModel.swift */, 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */, + BB22CC33DD44EE55FF66AA12 /* UserProfileViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -272,6 +270,14 @@ path = Services; sourceTree = ""; }; + E6A2B4C07F1D38E0A95B3C72 /* Search */ = { + isa = PBXGroup; + children = ( + C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */, + ); + path = Search; + sourceTree = ""; + }; 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"; diff --git a/ios/LibNovel/LibNovel/App/RootTabView.swift b/ios/LibNovel/LibNovel/App/RootTabView.swift index c3c374f..e6cd8ee 100644 --- a/ios/LibNovel/LibNovel/App/RootTabView.swift +++ b/ios/LibNovel/LibNovel/App/RootTabView.swift @@ -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) } } diff --git a/ios/LibNovel/LibNovel/Extensions/NavDestination.swift b/ios/LibNovel/LibNovel/Extensions/NavDestination.swift index 5b87abf..93d9eb6 100644 --- a/ios/LibNovel/LibNovel/Extensions/NavDestination.swift +++ b/ios/LibNovel/LibNovel/Extensions/NavDestination.swift @@ -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) } } } diff --git a/ios/LibNovel/LibNovel/Extensions/String+App.swift b/ios/LibNovel/LibNovel/Extensions/String+App.swift index 2d2f179..d2287e5 100644 --- a/ios/LibNovel/LibNovel/Extensions/String+App.swift +++ b/ios/LibNovel/LibNovel/Extensions/String+App.swift @@ -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 -} diff --git a/ios/LibNovel/LibNovel/Models/Models.swift b/ios/LibNovel/LibNovel/Models/Models.swift index 567977c..2205330 100644 --- a/ios/LibNovel/LibNovel/Models/Models.swift +++ b/ios/LibNovel/LibNovel/Models/Models.swift @@ -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 { diff --git a/ios/LibNovel/LibNovel/Networking/APIClient.swift b/ios/LibNovel/LibNovel/Networking/APIClient.swift index eab46e6..e35a027 100644 --- a/ios/LibNovel/LibNovel/Networking/APIClient.swift +++ b/ios/LibNovel/LibNovel/Networking/APIClient.swift @@ -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) ?? [] } } diff --git a/ios/LibNovel/LibNovel/Resources/Info.plist b/ios/LibNovel/LibNovel/Resources/Info.plist index 8619255..aa594f9 100644 --- a/ios/LibNovel/LibNovel/Resources/Info.plist +++ b/ios/LibNovel/LibNovel/Resources/Info.plist @@ -23,6 +23,8 @@ UIBackgroundModes audio + fetch + processing UILaunchScreen diff --git a/ios/LibNovel/LibNovel/ViewModels/HomeViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/HomeViewModel.swift index 739ea25..503d971 100644 --- a/ios/LibNovel/LibNovel/ViewModels/HomeViewModel.swift +++ b/ios/LibNovel/LibNovel/ViewModels/HomeViewModel.swift @@ -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 diff --git a/ios/LibNovel/LibNovel/ViewModels/UserProfileViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/UserProfileViewModel.swift new file mode 100644 index 0000000..ce27872 --- /dev/null +++ b/ios/LibNovel/LibNovel/ViewModels/UserProfileViewModel.swift @@ -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) + } +} diff --git a/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift b/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift index d12bb8f..ef54f44 100644 --- a/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift +++ b/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift @@ -33,6 +33,7 @@ struct BookDetailView: View { .ignoresSafeArea(edges: .top) } .navigationBarTitleDisplayMode(.inline) + .appNavigationDestination() .toolbar { bookmarkButton } .task { await vm.load() } .errorAlert($vm.error) diff --git a/ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift b/ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift index 0103094..e040e57 100644 --- a/ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift +++ b/ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift @@ -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)) diff --git a/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift b/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift index 5211243..c25217c 100644 --- a/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift +++ b/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift @@ -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) { diff --git a/ios/LibNovel/LibNovel/Views/Home/HomeView.swift b/ios/LibNovel/LibNovel/Views/Home/HomeView.swift index 3f72dfe..b255a0c 100644 --- a/ios/LibNovel/LibNovel/Views/Home/HomeView.swift +++ b/ios/LibNovel/LibNovel/Views/Home/HomeView.swift @@ -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)")! + } +} diff --git a/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift b/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift index b13930a..ff4fd89 100644 --- a/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift +++ b/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift @@ -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)")! + } +} diff --git a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift index d03f6a0..d6a6889 100644 --- a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift +++ b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift @@ -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 diff --git a/ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift b/ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift new file mode 100644 index 0000000..ae02b0c --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift @@ -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) + } + } +} diff --git a/ios/LibNovel/LibNovel/Views/Search/SearchView.swift b/ios/LibNovel/LibNovel/Views/Search/SearchView.swift index 6d7c50e..6da5af4 100644 --- a/ios/LibNovel/LibNovel/Views/Search/SearchView.swift +++ b/ios/LibNovel/LibNovel/Views/Search/SearchView.swift @@ -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) } diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index 34fb7f6..0dfd004 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -127,6 +127,14 @@ async function pbPatch(path: string, body: unknown): Promise { }); } +async function pbDelete(path: string): Promise { + const token = await getToken(); + return fetch(`${PB_URL}${path}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }); +} + interface PBList { items: T[]; totalItems: number; @@ -286,10 +294,41 @@ export async function setProgress( if (!res.ok) { const body = await res.text().catch(() => ''); log.error('pocketbase', 'setProgress POST failed', { slug, chapter, status: res.status, body }); - } } } +/** + * Delete progress entry for a specific book (removes from library/continue reading). + */ +export async function deleteProgress( + sessionId: string, + slug: string, + userId?: string +): Promise { + const existing = await listOne( + 'progress', + progressFilter(sessionId, slug, userId) + ); + + if (!existing) { + log.debug('pocketbase', 'deleteProgress: no record found', { sessionId, slug, userId }); + return; + } + + const res = await pbDelete(`/api/collections/progress/records/${existing.id}`); + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('pocketbase', 'deleteProgress failed', { + slug, + id: existing.id, + status: res.status, + body + }); + throw new Error(`Failed to delete progress: ${res.status}`); + } + log.info('pocketbase', 'deleteProgress success', { slug, id: existing.id }); +} + /** * Merge anonymous session progress into a user account on login/register. * diff --git a/ui/src/routes/api/home/+server.ts b/ui/src/routes/api/home/+server.ts index 7d35368..62584cd 100644 --- a/ui/src/routes/api/home/+server.ts +++ b/ui/src/routes/api/home/+server.ts @@ -1,12 +1,19 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { listBooks, recentlyAddedBooks, allProgress, getHomeStats } from '$lib/server/pocketbase'; +import { + listBooks, + recentlyAddedBooks, + allProgress, + getHomeStats, + getSubscriptionFeed +} from '$lib/server/pocketbase'; import { log } from '$lib/server/logger'; import type { Book, Progress } from '$lib/server/pocketbase'; /** * GET /api/home - * Returns home screen data: continue-reading list, recently updated books, and stats. + * Returns home screen data: continue-reading list, recently updated books, stats, + * and subscription feed (books recently read by followed users). * Requires authentication (enforced by layout guard). */ export const GET: RequestHandler = async ({ locals }) => { @@ -36,6 +43,12 @@ export const GET: RequestHandler = async ({ locals }) => { const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug)); const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6); + // Subscription feed — only available for logged-in users with following + let subscriptionFeed: Array<{ book: Book; readerUsername: string }> = []; + if (locals.user?.id) { + subscriptionFeed = await getSubscriptionFeed(locals.user.id).catch(() => []); + } + return json({ continue_reading: continueReading, recently_updated: recentlyUpdated, @@ -43,6 +56,10 @@ export const GET: RequestHandler = async ({ locals }) => { totalBooks: stats.totalBooks, totalChapters: stats.totalChapters, booksInProgress: continueReading.length - } + }, + subscription_feed: subscriptionFeed.map((item) => ({ + book: item.book, + readerUsername: item.readerUsername + })) }); }; diff --git a/ui/src/routes/api/progress/[slug]/+server.ts b/ui/src/routes/api/progress/[slug]/+server.ts index 658d409..498703e 100644 --- a/ui/src/routes/api/progress/[slug]/+server.ts +++ b/ui/src/routes/api/progress/[slug]/+server.ts @@ -1,6 +1,6 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { setProgress } from '$lib/server/pocketbase'; +import { setProgress, deleteProgress } from '$lib/server/pocketbase'; import { log } from '$lib/server/logger'; /** @@ -32,3 +32,23 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { return json({ ok: true }); }; + +/** + * DELETE /api/progress/[slug] + * Removes reading progress for a specific book (removes from library/continue reading). + */ +export const DELETE: RequestHandler = async ({ params, locals }) => { + const { slug } = params; + + try { + await deleteProgress(locals.sessionId, slug, locals.user?.id); + } catch (e) { + log.error('api/progress/[slug]', 'deleteProgress failed', { + slug, + err: String(e) + }); + error(500, 'Failed to delete progress'); + } + + return json({ ok: true }); +}; diff --git a/ui/src/routes/api/users/[username]/library/+server.ts b/ui/src/routes/api/users/[username]/library/+server.ts new file mode 100644 index 0000000..8fcb739 --- /dev/null +++ b/ui/src/routes/api/users/[username]/library/+server.ts @@ -0,0 +1,43 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { + getUserByUsername, + getUserPublicLibrary, + getUserCurrentlyReading +} from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * GET /api/users/[username]/library + * Returns the public library + currently-reading list for a user. + * Does not require authentication — all data is public. + */ +export const GET: RequestHandler = async ({ params }) => { + const { username } = params; + + const user = await getUserByUsername(username).catch(() => null); + if (!user) error(404, `User "${username}" not found`); + + try { + const [currentlyReading, library] = await Promise.all([ + getUserCurrentlyReading(user.id), + getUserPublicLibrary(user.id) + ]); + + return json({ + currently_reading: currentlyReading.map((item) => ({ + book: item.book, + last_chapter: item.chapter, + saved: false + })), + library: library.map((item) => ({ + book: item.book, + last_chapter: item.chapter, + saved: item.saved + })) + }); + } catch (e) { + log.error('api/users/library', 'failed to load library', { username, err: String(e) }); + error(500, 'Failed to load library'); + } +};