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
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:
@@ -19,19 +19,21 @@
|
|||||||
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */; };
|
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */; };
|
||||||
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F219788AE5ACBD6F240674F5 /* AuthStore.swift */; };
|
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F219788AE5ACBD6F240674F5 /* AuthStore.swift */; };
|
||||||
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B17D50389C6C98FC78BDBC /* ProfileView.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 */; };
|
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 */; };
|
7C74C10317D389121922A5E3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5A776719B77EDDB5E44743B0 /* Assets.xcassets */; };
|
||||||
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */; };
|
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */; };
|
||||||
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.swift */; };
|
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.swift */; };
|
||||||
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEF6782A2A28B2A485CBD48 /* AuthView.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 */; };
|
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 */; };
|
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 */; };
|
C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB2E843D93461074A89A171 /* HomeViewModel.swift */; };
|
||||||
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 09584EAB68A07B47F876A062 /* Kingfisher */; };
|
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 */; };
|
E1F564399D1325F6A1B2B84F /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C21107BECA55C07416E0CB8B /* LibraryView.swift */; };
|
||||||
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D83BB88C4306BE7A4F947CB /* Color+App.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 */; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViews.swift; sourceTree = "<group>"; };
|
||||||
@@ -138,18 +142,11 @@
|
|||||||
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */,
|
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */,
|
||||||
D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */,
|
D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */,
|
||||||
B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */,
|
B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */,
|
||||||
|
AA11BB22CC33DD44EE55FF67 /* UserProfileView.swift */,
|
||||||
);
|
);
|
||||||
path = Profile;
|
path = Profile;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
E6A2B4C07F1D38E0A95B3C72 /* Search */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */,
|
|
||||||
);
|
|
||||||
path = Search;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
426F7C5465758645B93A1AB1 /* Networking */ = {
|
426F7C5465758645B93A1AB1 /* Networking */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -259,6 +256,7 @@
|
|||||||
3AB2E843D93461074A89A171 /* HomeViewModel.swift */,
|
3AB2E843D93461074A89A171 /* HomeViewModel.swift */,
|
||||||
FC338B05EA6DB22900712000 /* LibraryViewModel.swift */,
|
FC338B05EA6DB22900712000 /* LibraryViewModel.swift */,
|
||||||
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */,
|
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */,
|
||||||
|
BB22CC33DD44EE55FF66AA12 /* UserProfileViewModel.swift */,
|
||||||
);
|
);
|
||||||
path = ViewModels;
|
path = ViewModels;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -272,6 +270,14 @@
|
|||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
E6A2B4C07F1D38E0A95B3C72 /* Search */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */,
|
||||||
|
);
|
||||||
|
path = Search;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
FA994FD601E79EC811D822A4 /* Library */ = {
|
FA994FD601E79EC811D822A4 /* Library */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -432,6 +438,8 @@
|
|||||||
C3D7A2E15F8B04C9AB163D50 /* AvatarCropView.swift in Sources */,
|
C3D7A2E15F8B04C9AB163D50 /* AvatarCropView.swift in Sources */,
|
||||||
A2F1C3B84E9D71A0BC164F28 /* AccountMenuSheet.swift in Sources */,
|
A2F1C3B84E9D71A0BC164F28 /* AccountMenuSheet.swift in Sources */,
|
||||||
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */,
|
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */,
|
||||||
|
AA11BB22CC33DD44EE55FF66 /* UserProfileView.swift in Sources */,
|
||||||
|
BB22CC33DD44EE55FF66AA11 /* UserProfileViewModel.swift in Sources */,
|
||||||
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */,
|
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */,
|
||||||
D5E2A1C96F3B08D0F74C6B50 /* SearchView.swift in Sources */,
|
D5E2A1C96F3B08D0F74C6B50 /* SearchView.swift in Sources */,
|
||||||
41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */,
|
41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */,
|
||||||
@@ -580,11 +588,11 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = GHZXC6FVMU;
|
DEVELOPMENT_TEAM = "";
|
||||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = GHZXC6FVMU;
|
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = GHZXC6FVMU;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
|
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
|
||||||
@@ -597,6 +605,7 @@
|
|||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.0.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel;
|
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel;
|
||||||
PROVISIONING_PROFILE = "af592c3a-f60b-4ac1-a14f-30b8a206017f";
|
PROVISIONING_PROFILE = "af592c3a-f60b-4ac1-a14f-30b8a206017f";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LibNovel Distribution";
|
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LibNovel Distribution";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ struct RootTabView: View {
|
|||||||
|
|
||||||
@State private var selectedTab: Tab = .home
|
@State private var selectedTab: Tab = .home
|
||||||
@State private var showFullPlayer: Bool = false
|
@State private var showFullPlayer: Bool = false
|
||||||
|
@State private var showCompactControls: Bool = false
|
||||||
|
|
||||||
/// Live drag offset while the user is dragging the full player down.
|
/// Live drag offset while the user is dragging the full player down.
|
||||||
@State private var fullPlayerDragOffset: CGFloat = 0
|
@State private var fullPlayerDragOffset: CGFloat = 0
|
||||||
@@ -16,9 +17,6 @@ struct RootTabView: View {
|
|||||||
case home, library, browse, search
|
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 {
|
var body: some View {
|
||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .bottom) {
|
||||||
TabView(selection: $selectedTab) {
|
TabView(selection: $selectedTab) {
|
||||||
@@ -38,20 +36,24 @@ struct RootTabView: View {
|
|||||||
.tabItem { Label("Search", systemImage: "magnifyingglass") }
|
.tabItem { Label("Search", systemImage: "magnifyingglass") }
|
||||||
.tag(Tab.search)
|
.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 {
|
if audioPlayer.isActive && !showFullPlayer {
|
||||||
MiniPlayerView(showFullPlayer: $showFullPlayer)
|
ZStack {
|
||||||
.padding(.bottom, tabBarHeight)
|
// Compact controls overlay (bottom sheet)
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
if showCompactControls {
|
||||||
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive)
|
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)
|
// 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)
|
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer)
|
||||||
}
|
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: showCompactControls)
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import SwiftUI
|
|||||||
enum NavDestination: Hashable {
|
enum NavDestination: Hashable {
|
||||||
case book(String) // slug
|
case book(String) // slug
|
||||||
case chapter(String, Int) // slug + chapter number
|
case chapter(String, Int) // slug + chapter number
|
||||||
|
case userProfile(String) // username
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - View extensions for shared navigation + error alert patterns
|
// MARK: - View extensions for shared navigation + error alert patterns
|
||||||
@@ -45,6 +46,8 @@ private struct AppNavigationDestinationModifier: ViewModifier {
|
|||||||
.navigationTransition(.zoom(sourceID: slug, in: zoomNamespace))
|
.navigationTransition(.zoom(sourceID: slug, in: zoomNamespace))
|
||||||
case .chapter(let slug, let n):
|
case .chapter(let slug, let n):
|
||||||
ChapterReaderView(slug: slug, chapterNumber: n)
|
ChapterReaderView(slug: slug, chapterNumber: n)
|
||||||
|
case .userProfile(let username):
|
||||||
|
UserProfileView(username: username)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Expose namespace to child views via environment
|
// Expose namespace to child views via environment
|
||||||
@@ -55,6 +58,7 @@ private struct AppNavigationDestinationModifier: ViewModifier {
|
|||||||
switch dest {
|
switch dest {
|
||||||
case .book(let slug): BookDetailView(slug: slug)
|
case .book(let slug): BookDetailView(slug: slug)
|
||||||
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
||||||
|
case .userProfile(let username): UserProfileView(username: username)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,11 +39,3 @@ extension String {
|
|||||||
return self
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
// MARK: - Audio
|
||||||
|
|
||||||
enum NextPrefetchStatus {
|
enum NextPrefetchStatus {
|
||||||
|
|||||||
@@ -189,6 +189,10 @@ actor APIClient {
|
|||||||
let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "POST", body: Body(chapter: chapter))
|
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? {
|
func audioTime(slug: String, chapter: Int) async throws -> Double? {
|
||||||
struct Response: Decodable { let audioTime: Double?; enum CodingKeys: String, CodingKey { case audioTime = "audio_time" } }
|
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)")
|
let r: Response = try await fetch("/api/progress/audio-time?slug=\(slug)&chapter=\(chapter)")
|
||||||
@@ -335,6 +339,30 @@ actor APIClient {
|
|||||||
return result.avatarURL
|
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
|
// MARK: - Comments
|
||||||
|
|
||||||
func fetchComments(slug: String, sort: String = "top") async throws -> CommentsResponse {
|
func fetchComments(slug: String, sort: String = "top") async throws -> CommentsResponse {
|
||||||
@@ -374,11 +402,21 @@ struct HomeDataResponse: Decodable {
|
|||||||
let continueReading: [ContinueItem]
|
let continueReading: [ContinueItem]
|
||||||
let recentlyUpdated: [Book]
|
let recentlyUpdated: [Book]
|
||||||
let stats: HomeStats
|
let stats: HomeStats
|
||||||
|
let subscriptionFeed: [SubscriptionFeedItem]
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case continueReading = "continue_reading"
|
case continueReading = "continue_reading"
|
||||||
case recentlyUpdated = "recently_updated"
|
case recentlyUpdated = "recently_updated"
|
||||||
case stats
|
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) ?? []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>audio</string>
|
<string>audio</string>
|
||||||
|
<string>fetch</string>
|
||||||
|
<string>processing</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict/>
|
<dict/>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ final class HomeViewModel: ObservableObject {
|
|||||||
@Published var continueReading: [ContinueReadingItem] = []
|
@Published var continueReading: [ContinueReadingItem] = []
|
||||||
@Published var recentlyUpdated: [Book] = []
|
@Published var recentlyUpdated: [Book] = []
|
||||||
@Published var stats: HomeStats?
|
@Published var stats: HomeStats?
|
||||||
|
@Published var subscriptionFeed: [SubscriptionFeedItem] = []
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var error: String?
|
@Published var error: String?
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ final class HomeViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
recentlyUpdated = data.recentlyUpdated
|
recentlyUpdated = data.recentlyUpdated
|
||||||
stats = data.stats
|
stats = data.stats
|
||||||
|
subscriptionFeed = data.subscriptionFeed
|
||||||
} catch {
|
} catch {
|
||||||
if !(error is CancellationError) {
|
if !(error is CancellationError) {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
|
|||||||
87
ios/LibNovel/LibNovel/ViewModels/UserProfileViewModel.swift
Normal file
87
ios/LibNovel/LibNovel/ViewModels/UserProfileViewModel.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ struct BookDetailView: View {
|
|||||||
.ignoresSafeArea(edges: .top)
|
.ignoresSafeArea(edges: .top)
|
||||||
}
|
}
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.appNavigationDestination()
|
||||||
.toolbar { bookmarkButton }
|
.toolbar { bookmarkButton }
|
||||||
.task { await vm.load() }
|
.task { await vm.load() }
|
||||||
.errorAlert($vm.error)
|
.errorAlert($vm.error)
|
||||||
|
|||||||
@@ -510,8 +510,13 @@ private struct CommentRow: View {
|
|||||||
// Avatar + Username + date
|
// Avatar + Username + date
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
avatarView
|
avatarView
|
||||||
Text(comment.username.isEmpty ? "Anonymous" : comment.username)
|
NavigationLink(value: NavDestination.userProfile(comment.username.isEmpty ? "" : comment.username)) {
|
||||||
.font(isReply ? .caption.weight(.medium) : .subheadline.weight(.medium))
|
Text(comment.username.isEmpty ? "Anonymous" : comment.username)
|
||||||
|
.font(isReply ? .caption.weight(.medium) : .subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(comment.username.isEmpty)
|
||||||
Text("·")
|
Text("·")
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
Text(formattedDate(comment.created))
|
Text(formattedDate(comment.created))
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ struct ChapterReaderView: View {
|
|||||||
.task(id: currentChapter) { await vm.load() }
|
.task(id: currentChapter) { await vm.load() }
|
||||||
.sheet(isPresented: $showSettingsPanel) {
|
.sheet(isPresented: $showSettingsPanel) {
|
||||||
ReaderSettingsPanel(store: readerSettings, isPresented: $showSettingsPanel)
|
ReaderSettingsPanel(store: readerSettings, isPresented: $showSettingsPanel)
|
||||||
.presentationDetents([.height(380)])
|
.presentationDetents([.height(480)])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
.presentationCornerRadius(20)
|
.presentationCornerRadius(20)
|
||||||
}
|
}
|
||||||
@@ -802,6 +802,7 @@ struct ReaderSettingsPanel: View {
|
|||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
// Font size row
|
// Font size row
|
||||||
|
|
||||||
@@ -903,6 +904,7 @@ struct ReaderSettingsPanel: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func adjustFontSize(_ delta: CGFloat) {
|
private func adjustFontSize(_ delta: CGFloat) {
|
||||||
|
|||||||
@@ -20,6 +20,17 @@ struct HomeView: View {
|
|||||||
ContinueReadingCard(item: item)
|
ContinueReadingCard(item: item)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.contextMenu {
|
||||||
|
ContinueReadingContextMenu(
|
||||||
|
item: item,
|
||||||
|
onMarkFinished: {
|
||||||
|
Task { await markAsFinished(item.book) }
|
||||||
|
},
|
||||||
|
onRemove: {
|
||||||
|
Task { await removeFromLibrary(item.book.slug) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
@@ -45,6 +56,34 @@ struct HomeView: View {
|
|||||||
ShelfBookCard(book: book)
|
ShelfBookCard(book: book)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.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)
|
.padding(.horizontal)
|
||||||
@@ -54,7 +93,7 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Empty state
|
// Empty state
|
||||||
if vm.continueReading.isEmpty && vm.recentlyUpdated.isEmpty && !vm.isLoading {
|
if vm.continueReading.isEmpty && vm.recentlyUpdated.isEmpty && vm.subscriptionFeed.isEmpty && !vm.isLoading {
|
||||||
EmptyStateView(
|
EmptyStateView(
|
||||||
icon: "books.vertical",
|
icon: "books.vertical",
|
||||||
title: "Your library is empty",
|
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
|
// MARK: - Shelf header
|
||||||
@@ -141,20 +204,20 @@ private struct ContinueReadingCard: View {
|
|||||||
.frame(width: 130, alignment: .leading)
|
.frame(width: 130, alignment: .leading)
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
// Progress bar
|
// Progress bar — show at least a 4pt sliver so early chapters aren't invisible
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(Color.secondary.opacity(0.2))
|
.fill(Color.secondary.opacity(0.2))
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(Color.amber.opacity(0.85))
|
.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)
|
.frame(width: 130, height: 3)
|
||||||
|
|
||||||
// Percent label
|
// Percent label — floor at 1% so early chapters don't display "0%"
|
||||||
Text("\(Int(progressFraction * 100))% complete")
|
Text("\(max(1, Int(progressFraction * 100)))% complete")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.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)
|
// MARK: - Stats strip (compact inline)
|
||||||
|
|
||||||
private struct StatsStrip: View {
|
private struct StatsStrip: View {
|
||||||
@@ -228,3 +322,51 @@ private struct StatPill: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.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)")!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -192,23 +192,39 @@ struct LibraryView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.top, 60)
|
.padding(.top, 60)
|
||||||
} else {
|
} else {
|
||||||
// 3-column grid
|
// 2-column grid (matches Discover)
|
||||||
LazyVGrid(
|
LazyVGrid(
|
||||||
columns: [
|
columns: [
|
||||||
GridItem(.flexible(), spacing: 12),
|
|
||||||
GridItem(.flexible(), spacing: 12),
|
GridItem(.flexible(), spacing: 12),
|
||||||
GridItem(.flexible(), spacing: 12)
|
GridItem(.flexible(), spacing: 12)
|
||||||
],
|
],
|
||||||
spacing: 20
|
spacing: 16
|
||||||
) {
|
) {
|
||||||
ForEach(filtered) { item in
|
ForEach(filtered) { item in
|
||||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||||
LibraryBookCard(item: item)
|
LibraryBookCard(item: item)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.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(.horizontal)
|
||||||
|
.padding(.top, 8)
|
||||||
.padding(.bottom, 24)
|
.padding(.bottom, 24)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,6 +254,24 @@ struct LibraryView: View {
|
|||||||
return "No completed books yet."
|
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)
|
// MARK: - Library book card (3-column)
|
||||||
@@ -291,14 +325,14 @@ private struct LibraryBookCard: View {
|
|||||||
|
|
||||||
// Title
|
// Title
|
||||||
Text(item.book.title)
|
Text(item.book.title)
|
||||||
.font(.caption.bold())
|
.font(.subheadline.bold())
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
// Chapter badge if present
|
// Chapter badge if present
|
||||||
if let ch = item.lastChapter {
|
if let ch = item.lastChapter {
|
||||||
Text(isCompleted ? "Finished" : "Ch.\(ch)")
|
Text(isCompleted ? "Finished" : "Ch.\(ch)")
|
||||||
.font(.caption2)
|
.font(.caption)
|
||||||
.foregroundStyle(isCompleted ? Color.amber : .secondary)
|
.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)")!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,291 @@ import SwiftUI
|
|||||||
import Kingfisher // used directly for blurred background in FullPlayerView
|
import Kingfisher // used directly for blurred background in FullPlayerView
|
||||||
import AVKit // for AVRoutePickerView (AirPlay)
|
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
|
@Binding var showFullPlayer: Bool
|
||||||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||||||
|
|
||||||
|
|||||||
209
ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift
Normal file
209
ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,7 +115,6 @@ struct SearchView: View {
|
|||||||
|
|
||||||
LazyVGrid(
|
LazyVGrid(
|
||||||
columns: [
|
columns: [
|
||||||
GridItem(.flexible(), spacing: 12),
|
|
||||||
GridItem(.flexible(), spacing: 12),
|
GridItem(.flexible(), spacing: 12),
|
||||||
GridItem(.flexible(), spacing: 12)
|
GridItem(.flexible(), spacing: 12)
|
||||||
],
|
],
|
||||||
@@ -145,7 +144,6 @@ struct SearchView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVGrid(
|
LazyVGrid(
|
||||||
columns: [
|
columns: [
|
||||||
GridItem(.flexible(), spacing: 12),
|
|
||||||
GridItem(.flexible(), spacing: 12),
|
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 {
|
private struct SearchNovelCard: View {
|
||||||
let novel: BrowseNovel
|
let novel: BrowseNovel
|
||||||
@@ -178,7 +176,7 @@ private struct SearchNovelCard: View {
|
|||||||
.bookCoverZoomSource(slug: novel.slug)
|
.bookCoverZoomSource(slug: novel.slug)
|
||||||
|
|
||||||
Text(novel.title)
|
Text(novel.title)
|
||||||
.font(.caption.bold())
|
.font(.subheadline.bold())
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,14 @@ async function pbPatch(path: string, body: unknown): Promise<Response> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pbDelete(path: string): Promise<Response> {
|
||||||
|
const token = await getToken();
|
||||||
|
return fetch(`${PB_URL}${path}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
interface PBList<T> {
|
interface PBList<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
@@ -286,10 +294,41 @@ export async function setProgress(
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.text().catch(() => '');
|
const body = await res.text().catch(() => '');
|
||||||
log.error('pocketbase', 'setProgress POST failed', { slug, chapter, status: res.status, body });
|
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<void> {
|
||||||
|
const existing = await listOne<Progress & { id: string }>(
|
||||||
|
'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.
|
* Merge anonymous session progress into a user account on login/register.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
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 { log } from '$lib/server/logger';
|
||||||
import type { Book, Progress } from '$lib/server/pocketbase';
|
import type { Book, Progress } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/home
|
* 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).
|
* Requires authentication (enforced by layout guard).
|
||||||
*/
|
*/
|
||||||
export const GET: RequestHandler = async ({ locals }) => {
|
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 inProgressSlugs = new Set(continueReading.map((c) => c.book.slug));
|
||||||
const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6);
|
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({
|
return json({
|
||||||
continue_reading: continueReading,
|
continue_reading: continueReading,
|
||||||
recently_updated: recentlyUpdated,
|
recently_updated: recentlyUpdated,
|
||||||
@@ -43,6 +56,10 @@ export const GET: RequestHandler = async ({ locals }) => {
|
|||||||
totalBooks: stats.totalBooks,
|
totalBooks: stats.totalBooks,
|
||||||
totalChapters: stats.totalChapters,
|
totalChapters: stats.totalChapters,
|
||||||
booksInProgress: continueReading.length
|
booksInProgress: continueReading.length
|
||||||
}
|
},
|
||||||
|
subscription_feed: subscriptionFeed.map((item) => ({
|
||||||
|
book: item.book,
|
||||||
|
readerUsername: item.readerUsername
|
||||||
|
}))
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { setProgress } from '$lib/server/pocketbase';
|
import { setProgress, deleteProgress } from '$lib/server/pocketbase';
|
||||||
import { log } from '$lib/server/logger';
|
import { log } from '$lib/server/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,3 +32,23 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
|||||||
|
|
||||||
return json({ ok: true });
|
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 });
|
||||||
|
};
|
||||||
|
|||||||
43
ui/src/routes/api/users/[username]/library/+server.ts
Normal file
43
ui/src/routes/api/users/[username]/library/+server.ts
Normal file
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user