From f51113a2f896eae0e5ab0b97d3950b61a649d893 Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 7 Mar 2026 18:17:51 +0500 Subject: [PATCH] Add iOS app, SvelteKit JSON API endpoints, and Gitea CI workflow - iOS SwiftUI app (ios/LibNovel/) targeting iOS 17+, generated via xcodegen - Full feature set: auth, home, library, book detail, chapter reader, browse, audio player, profile - Kingfisher for image loading, swift-markdown-ui for chapter rendering - Base URL: https://v2.libnovel.kalekber.cc - SvelteKit JSON API routes (ui/src/routes/api/) for iOS consumption: auth/login, auth/register, auth/me, auth/logout, auth/change-password, home, library, book/[slug], chapter/[slug]/[n], search, ranking, progress/[slug], presign/audio (updated) - Gitea Actions CI: .gitea/workflows/ios.yaml (build + test on macos-latest) - justfile: ios-gen, ios-build, ios-test recipes --- .gitea/workflows/ios.yaml | 169 +++++ ios/.gitignore | 14 + .../LibNovel.xcodeproj/project.pbxproj | 693 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/swiftpm/Package.resolved | 42 ++ .../xcshareddata/xcschemes/LibNovel.xcscheme | 113 +++ ios/LibNovel/LibNovel/App/ContentView.swift | 16 + ios/LibNovel/LibNovel/App/LibNovelApp.swift | 15 + ios/LibNovel/LibNovel/App/RootTabView.swift | 63 ++ .../LibNovel/Extensions/Color+App.swift | 11 + .../LibNovel/Extensions/NavDestination.swift | 8 + ios/LibNovel/LibNovel/Models/Models.swift | 259 +++++++ .../LibNovel/Networking/APIClient.swift | 416 +++++++++++ .../AccentColor.colorset/Contents.json | 12 + .../AppIcon.appiconset/Contents.json | 13 + .../Resources/Assets.xcassets/Contents.json | 3 + ios/LibNovel/LibNovel/Resources/Info.plist | 43 ++ .../Services/AudioPlayerService.swift | 368 ++++++++++ .../LibNovel/Services/AuthStore.swift | 139 ++++ .../ViewModels/BookDetailViewModel.swift | 46 ++ .../LibNovel/ViewModels/BrowseViewModel.swift | 69 ++ .../ViewModels/ChapterReaderViewModel.swift | 53 ++ .../LibNovel/ViewModels/HomeViewModel.swift | 26 + .../ViewModels/LibraryViewModel.swift | 19 + .../ViewModels/ProfileViewModel.swift | 40 + .../LibNovel/Views/Auth/AuthView.swift | 123 ++++ .../Views/BookDetail/BookDetailView.swift | 220 ++++++ .../LibNovel/Views/Browse/BrowseView.swift | 198 +++++ .../ChapterReader/ChapterReaderView.swift | 157 ++++ .../LibNovel/Views/Common/CommonViews.swift | 74 ++ .../LibNovel/Views/Home/HomeView.swift | 148 ++++ .../LibNovel/Views/Library/LibraryView.swift | 86 +++ .../LibNovel/Views/Player/PlayerViews.swift | 192 +++++ .../LibNovel/Views/Profile/ProfileView.swift | 218 ++++++ .../LibNovelTests/LibNovelTests.swift | 9 + ios/LibNovel/project.yml | 90 +++ justfile | 25 + .../api/auth/change-password/+server.ts | 47 ++ ui/src/routes/api/auth/login/+server.ts | 75 ++ ui/src/routes/api/auth/logout/+server.ts | 15 + ui/src/routes/api/auth/me/+server.ts | 19 + ui/src/routes/api/auth/register/+server.ts | 84 +++ ui/src/routes/api/book/[slug]/+server.ts | 105 +++ .../routes/api/chapter/[slug]/[n]/+server.ts | 125 ++++ ui/src/routes/api/home/+server.ts | 48 ++ ui/src/routes/api/library/+server.ts | 61 ++ ui/src/routes/api/presign/audio/+server.ts | 3 +- ui/src/routes/api/progress/[slug]/+server.ts | 34 + ui/src/routes/api/ranking/+server.ts | 27 + ui/src/routes/api/search/+server.ts | 36 + 50 files changed, 4875 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/ios.yaml create mode 100644 ios/.gitignore create mode 100644 ios/LibNovel/LibNovel.xcodeproj/project.pbxproj create mode 100644 ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 ios/LibNovel/LibNovel.xcodeproj/xcshareddata/xcschemes/LibNovel.xcscheme create mode 100644 ios/LibNovel/LibNovel/App/ContentView.swift create mode 100644 ios/LibNovel/LibNovel/App/LibNovelApp.swift create mode 100644 ios/LibNovel/LibNovel/App/RootTabView.swift create mode 100644 ios/LibNovel/LibNovel/Extensions/Color+App.swift create mode 100644 ios/LibNovel/LibNovel/Extensions/NavDestination.swift create mode 100644 ios/LibNovel/LibNovel/Models/Models.swift create mode 100644 ios/LibNovel/LibNovel/Networking/APIClient.swift create mode 100644 ios/LibNovel/LibNovel/Resources/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 ios/LibNovel/LibNovel/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/LibNovel/LibNovel/Resources/Assets.xcassets/Contents.json create mode 100644 ios/LibNovel/LibNovel/Resources/Info.plist create mode 100644 ios/LibNovel/LibNovel/Services/AudioPlayerService.swift create mode 100644 ios/LibNovel/LibNovel/Services/AuthStore.swift create mode 100644 ios/LibNovel/LibNovel/ViewModels/BookDetailViewModel.swift create mode 100644 ios/LibNovel/LibNovel/ViewModels/BrowseViewModel.swift create mode 100644 ios/LibNovel/LibNovel/ViewModels/ChapterReaderViewModel.swift create mode 100644 ios/LibNovel/LibNovel/ViewModels/HomeViewModel.swift create mode 100644 ios/LibNovel/LibNovel/ViewModels/LibraryViewModel.swift create mode 100644 ios/LibNovel/LibNovel/ViewModels/ProfileViewModel.swift create mode 100644 ios/LibNovel/LibNovel/Views/Auth/AuthView.swift create mode 100644 ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift create mode 100644 ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift create mode 100644 ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift create mode 100644 ios/LibNovel/LibNovel/Views/Common/CommonViews.swift create mode 100644 ios/LibNovel/LibNovel/Views/Home/HomeView.swift create mode 100644 ios/LibNovel/LibNovel/Views/Library/LibraryView.swift create mode 100644 ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift create mode 100644 ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift create mode 100644 ios/LibNovel/LibNovelTests/LibNovelTests.swift create mode 100644 ios/LibNovel/project.yml create mode 100644 ui/src/routes/api/auth/change-password/+server.ts create mode 100644 ui/src/routes/api/auth/login/+server.ts create mode 100644 ui/src/routes/api/auth/logout/+server.ts create mode 100644 ui/src/routes/api/auth/me/+server.ts create mode 100644 ui/src/routes/api/auth/register/+server.ts create mode 100644 ui/src/routes/api/book/[slug]/+server.ts create mode 100644 ui/src/routes/api/chapter/[slug]/[n]/+server.ts create mode 100644 ui/src/routes/api/home/+server.ts create mode 100644 ui/src/routes/api/library/+server.ts create mode 100644 ui/src/routes/api/progress/[slug]/+server.ts create mode 100644 ui/src/routes/api/ranking/+server.ts create mode 100644 ui/src/routes/api/search/+server.ts diff --git a/.gitea/workflows/ios.yaml b/.gitea/workflows/ios.yaml new file mode 100644 index 0000000..16e10c1 --- /dev/null +++ b/.gitea/workflows/ios.yaml @@ -0,0 +1,169 @@ +name: iOS CI + +on: + push: + branches: ["v2", "main"] + paths: + - "ios/**" + - ".gitea/workflows/ios.yaml" + pull_request: + branches: ["v2", "main"] + paths: + - "ios/**" + - ".gitea/workflows/ios.yaml" + +defaults: + run: + working-directory: ios/LibNovel + +jobs: + # ── build ───────────────────────────────────────────────────────────────── + build: + name: Build + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install xcodegen + run: brew install xcodegen + + - name: Generate Xcode project + run: xcodegen generate --spec project.yml --project . + + - name: Resolve SPM packages + run: | + xcodebuild \ + -project LibNovel.xcodeproj \ + -scheme LibNovel \ + -resolvePackageDependencies \ + -clonedSourcePackagesDirPath .spm-cache + + - name: Build (simulator) + run: | + xcodebuild \ + -project LibNovel.xcodeproj \ + -scheme LibNovel \ + -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + -clonedSourcePackagesDirPath .spm-cache \ + CODE_SIGNING_ALLOWED=NO \ + | xcpretty || xcodebuild \ + -project LibNovel.xcodeproj \ + -scheme LibNovel \ + -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + -clonedSourcePackagesDirPath .spm-cache \ + CODE_SIGNING_ALLOWED=NO + + # ── test ────────────────────────────────────────────────────────────────── + test: + name: Test + runs-on: macos-latest + needs: build + + steps: + - uses: actions/checkout@v4 + + - name: Install xcodegen + run: brew install xcodegen + + - name: Generate Xcode project + run: xcodegen generate --spec project.yml --project . + + - name: Resolve SPM packages + run: | + xcodebuild \ + -project LibNovel.xcodeproj \ + -scheme LibNovel \ + -resolvePackageDependencies \ + -clonedSourcePackagesDirPath .spm-cache + + - name: Run unit tests + run: | + xcodebuild test \ + -project LibNovel.xcodeproj \ + -scheme LibNovel \ + -configuration Debug \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -clonedSourcePackagesDirPath .spm-cache \ + CODE_SIGNING_ALLOWED=NO \ + | xcpretty --report junit --output test-results.xml || true + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: ios/LibNovel/test-results.xml + retention-days: 7 + + # ── archive (release IPA) ───────────────────────────────────────────────── + # Runs only on pushes to v2/main (not PRs). + # Requires secrets: APPLE_CERTIFICATE_BASE64, APPLE_CERTIFICATE_PASSWORD, + # APPLE_PROVISIONING_PROFILE_BASE64, KEYCHAIN_PASSWORD + # + # archive: + # name: Archive + # runs-on: macos-latest + # needs: [build, test] + # if: gitea.event_name == 'push' + # + # steps: + # - uses: actions/checkout@v4 + # + # - name: Install xcodegen + # run: brew install xcodegen + # + # - name: Generate Xcode project + # run: xcodegen generate --spec project.yml --project . + # working-directory: ios/LibNovel + # + # - name: Import signing certificate + # env: + # CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + # CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + # KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + # run: | + # KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + # security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + # security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + # security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + # echo "$CERTIFICATE_BASE64" | base64 --decode > $RUNNER_TEMP/cert.p12 + # security import $RUNNER_TEMP/cert.p12 \ + # -P "$CERTIFICATE_PASSWORD" \ + # -A -t cert -f pkcs12 \ + # -k $KEYCHAIN_PATH + # security list-keychain -d user -s $KEYCHAIN_PATH + # + # - name: Import provisioning profile + # env: + # PROFILE_BASE64: ${{ secrets.APPLE_PROVISIONING_PROFILE_BASE64 }} + # run: | + # PP_PATH=$RUNNER_TEMP/profile.mobileprovision + # echo "$PROFILE_BASE64" | base64 --decode > $PP_PATH + # mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles + # cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/ + # + # - name: Archive + # run: | + # xcodebuild archive \ + # -project LibNovel.xcodeproj \ + # -scheme LibNovel \ + # -configuration Release \ + # -archivePath $RUNNER_TEMP/LibNovel.xcarchive + # working-directory: ios/LibNovel + # + # - name: Export IPA + # run: | + # xcodebuild -exportArchive \ + # -archivePath $RUNNER_TEMP/LibNovel.xcarchive \ + # -exportPath $RUNNER_TEMP/ipa \ + # -exportOptionsPlist ios/ExportOptions.plist + # + # - name: Upload IPA artifact + # uses: actions/upload-artifact@v4 + # with: + # name: LibNovel-release + # path: ${{ runner.temp }}/ipa/LibNovel.ipa + # retention-days: 30 diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..b1f0a6b --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,14 @@ +# Xcode build artifacts — regenerate with: xcodegen generate --spec project.yml +xcuserdata/ +*.xcuserstate +*.xcworkspace/xcuserdata/ +DerivedData/ +build/ + +# Swift Package Manager — resolved by Xcode on first open +LibNovel.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/ +.build/ +# Package.resolved is committed so SPM builds are reproducible + +# OS +.DS_Store diff --git a/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj b/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj new file mode 100644 index 0000000..93357fc --- /dev/null +++ b/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj @@ -0,0 +1,693 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 032E049A4BB3CF0EA990C0CD /* LibNovelApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F56C8E2BC3614530B81569D /* LibNovelApp.swift */; }; + 08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */; }; + 0A52BC1CE71BED9E75D20D35 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 762E378B9BC2161A7AA2CC36 /* Models.swift */; }; + 2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */; }; + 2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */; }; + 3521DFD5FCBBED7B90368829 /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC338B05EA6DB22900712000 /* LibraryViewModel.swift */; }; + 367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5C115992F1CE2326236765 /* RootTabView.swift */; }; + 4BB2C76262D5BD5DAD0D5D28 /* LibNovelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C918833E173D6B44D06955 /* LibNovelTests.swift */; }; + 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 */; }; + 749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39DE056C37FBC5EED8771821 /* BookDetailView.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 */; }; + A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB13E89E50529E3081533A66 /* AudioPlayerService.swift */; }; + BD2CA5EE70D102CA3B153485 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 6313AB4B3A5464F647791174 /* MarkdownUI */; }; + BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */; }; + C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB2E843D93461074A89A171 /* HomeViewModel.swift */; }; + CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 09584EAB68A07B47F876A062 /* Kingfisher */; }; + 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 */; }; + F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E89FD8F46747CA653C5203D /* CommonViews.swift */; }; + F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CAFB96D2500F34F0B0C860C /* NavDestination.swift */; }; + FB32F3772CA09684F00497F3 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B593F179EC3E9112126B540B /* APIClient.swift */; }; + FEFB5FDC2424D22914458001 /* ChapterReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 698AC3AA533BC05C985595D0 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A10A669C0C8B43078C0FEE9F /* Project object */; + proxyType = 1; + remoteGlobalIDString = D039EDECDE3998D8534BB680; + remoteInfo = LibNovel; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 1B8BF3DB582A658386E402C7 /* LibNovel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LibNovel.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = ""; }; + 235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; 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 = ""; }; + 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 = ""; }; + 5A776719B77EDDB5E44743B0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 762E378B9BC2161A7AA2CC36 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + 7CAFB96D2500F34F0B0C860C /* NavDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavDestination.swift; sourceTree = ""; }; + 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; + 81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderView.swift; sourceTree = ""; }; + 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailViewModel.swift; sourceTree = ""; }; + 8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderViewModel.swift; sourceTree = ""; }; + 8E89FD8F46747CA653C5203D /* CommonViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonViews.swift; sourceTree = ""; }; + 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 = ""; }; + B4C918833E173D6B44D06955 /* LibNovelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelTests.swift; sourceTree = ""; }; + B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; + C0B17D50389C6C98FC78BDBC /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; + C21107BECA55C07416E0CB8B /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.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 = ""; }; + F219788AE5ACBD6F240674F5 /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = ""; }; + FC338B05EA6DB22900712000 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + EFE3211B202EDF04EB141EFB /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */, + BD2CA5EE70D102CA3B153485 /* MarkdownUI in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 2C0FB0EDFF9B3E24B97F4214 /* Resources */ = { + isa = PBXGroup; + children = ( + 5A776719B77EDDB5E44743B0 /* Assets.xcassets */, + ); + path = Resources; + sourceTree = ""; + }; + 2C57B93EAF19A3B18E7B7E87 /* Views */ = { + isa = PBXGroup; + children = ( + 2F18D1275D6022B9847E310E /* Auth */, + FB5C0D4925633786D28C6DE3 /* BookDetail */, + 8E8AAA58A33084ADB8AEA80C /* Browse */, + 4EAB87A1ED4943A311F26F84 /* ChapterReader */, + 5D5809803A3D74FAE19DB218 /* Common */, + 811FC0F6B9C209D6EC8543BD /* Home */, + FA994FD601E79EC811D822A4 /* Library */, + 89F2CB14192E7D7565A588E0 /* Player */, + 3DB66C5703A4CCAFFA1B7AFE /* Profile */, + ); + path = Views; + sourceTree = ""; + }; + 2F18D1275D6022B9847E310E /* Auth */ = { + isa = PBXGroup; + children = ( + 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */, + ); + path = Auth; + sourceTree = ""; + }; + 3DB66C5703A4CCAFFA1B7AFE /* Profile */ = { + isa = PBXGroup; + children = ( + C0B17D50389C6C98FC78BDBC /* ProfileView.swift */, + ); + path = Profile; + sourceTree = ""; + }; + 426F7C5465758645B93A1AB1 /* Networking */ = { + isa = PBXGroup; + children = ( + B593F179EC3E9112126B540B /* APIClient.swift */, + ); + path = Networking; + sourceTree = ""; + }; + 4EAB87A1ED4943A311F26F84 /* ChapterReader */ = { + isa = PBXGroup; + children = ( + 81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */, + ); + path = ChapterReader; + sourceTree = ""; + }; + 5D5809803A3D74FAE19DB218 /* Common */ = { + isa = PBXGroup; + children = ( + 8E89FD8F46747CA653C5203D /* CommonViews.swift */, + ); + path = Common; + sourceTree = ""; + }; + 6318D3C6F0DC6C8E2C377103 /* Products */ = { + isa = PBXGroup; + children = ( + 1B8BF3DB582A658386E402C7 /* LibNovel.app */, + 235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 646952B9CE927F8038FF0A13 /* LibNovelTests */ = { + isa = PBXGroup; + children = ( + B4C918833E173D6B44D06955 /* LibNovelTests.swift */, + ); + path = LibNovelTests; + sourceTree = ""; + }; + 80148B5E27BD0A3DEDB3ADAA /* Models */ = { + isa = PBXGroup; + children = ( + 762E378B9BC2161A7AA2CC36 /* Models.swift */, + ); + path = Models; + sourceTree = ""; + }; + 811FC0F6B9C209D6EC8543BD /* Home */ = { + isa = PBXGroup; + children = ( + D6268D60803940CBD38FB921 /* HomeView.swift */, + ); + path = Home; + sourceTree = ""; + }; + 89F2CB14192E7D7565A588E0 /* Player */ = { + isa = PBXGroup; + children = ( + DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */, + ); + path = Player; + sourceTree = ""; + }; + 8E8AAA58A33084ADB8AEA80C /* Browse */ = { + isa = PBXGroup; + children = ( + 1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */, + ); + path = Browse; + sourceTree = ""; + }; + 9AF55E5D62F980C72431782A = { + isa = PBXGroup; + children = ( + A28A184E73B15138A4D13F31 /* LibNovel */, + 646952B9CE927F8038FF0A13 /* LibNovelTests */, + 6318D3C6F0DC6C8E2C377103 /* Products */, + ); + indentWidth = 4; + sourceTree = ""; + tabWidth = 4; + usesTabs = 0; + }; + A28A184E73B15138A4D13F31 /* LibNovel */ = { + isa = PBXGroup; + children = ( + FE92158CC5DA9AD446062724 /* App */, + FD5EDEE9747643D45CA6423E /* Extensions */, + 80148B5E27BD0A3DEDB3ADAA /* Models */, + 426F7C5465758645B93A1AB1 /* Networking */, + 2C0FB0EDFF9B3E24B97F4214 /* Resources */, + DA6F6F625578875F3E74F1D3 /* Services */, + B6916C5C762A37AB1279DF44 /* ViewModels */, + 2C57B93EAF19A3B18E7B7E87 /* Views */, + ); + path = LibNovel; + sourceTree = ""; + }; + B6916C5C762A37AB1279DF44 /* ViewModels */ = { + isa = PBXGroup; + children = ( + 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */, + 9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */, + 8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */, + 3AB2E843D93461074A89A171 /* HomeViewModel.swift */, + FC338B05EA6DB22900712000 /* LibraryViewModel.swift */, + 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + DA6F6F625578875F3E74F1D3 /* Services */ = { + isa = PBXGroup; + children = ( + DB13E89E50529E3081533A66 /* AudioPlayerService.swift */, + F219788AE5ACBD6F240674F5 /* AuthStore.swift */, + ); + path = Services; + sourceTree = ""; + }; + FA994FD601E79EC811D822A4 /* Library */ = { + isa = PBXGroup; + children = ( + C21107BECA55C07416E0CB8B /* LibraryView.swift */, + ); + path = Library; + sourceTree = ""; + }; + FB5C0D4925633786D28C6DE3 /* BookDetail */ = { + isa = PBXGroup; + children = ( + 39DE056C37FBC5EED8771821 /* BookDetailView.swift */, + ); + path = BookDetail; + sourceTree = ""; + }; + FD5EDEE9747643D45CA6423E /* Extensions */ = { + isa = PBXGroup; + children = ( + 9D83BB88C4306BE7A4F947CB /* Color+App.swift */, + 7CAFB96D2500F34F0B0C860C /* NavDestination.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + FE92158CC5DA9AD446062724 /* App */ = { + isa = PBXGroup; + children = ( + 4B820081FA4817765A39939A /* ContentView.swift */, + 4F56C8E2BC3614530B81569D /* LibNovelApp.swift */, + 2D5C115992F1CE2326236765 /* RootTabView.swift */, + ); + path = App; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 5E6D3E8266BFCF0AAF5EC79D /* LibNovelTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 964FF85B62FA35E819BE7661 /* Build configuration list for PBXNativeTarget "LibNovelTests" */; + buildPhases = ( + 247D45B3DB26CAC41FA78A0B /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 9FD4A50EB175FC09D6BFD28D /* PBXTargetDependency */, + ); + name = LibNovelTests; + packageProductDependencies = ( + ); + productName = LibNovelTests; + productReference = 235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + D039EDECDE3998D8534BB680 /* LibNovel */ = { + isa = PBXNativeTarget; + buildConfigurationList = 29B2DE7267A3A4B2D89B32DA /* Build configuration list for PBXNativeTarget "LibNovel" */; + buildPhases = ( + 48661ADCA15B54E048CF694C /* Sources */, + 27446CA4728C022832398376 /* Resources */, + EFE3211B202EDF04EB141EFB /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = LibNovel; + packageProductDependencies = ( + 09584EAB68A07B47F876A062 /* Kingfisher */, + 6313AB4B3A5464F647791174 /* MarkdownUI */, + ); + productName = LibNovel; + productReference = 1B8BF3DB582A658386E402C7 /* LibNovel.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A10A669C0C8B43078C0FEE9F /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1600; + }; + buildConfigurationList = D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 9AF55E5D62F980C72431782A; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */, + C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 6318D3C6F0DC6C8E2C377103 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + D039EDECDE3998D8534BB680 /* LibNovel */, + 5E6D3E8266BFCF0AAF5EC79D /* LibNovelTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 27446CA4728C022832398376 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7C74C10317D389121922A5E3 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 247D45B3DB26CAC41FA78A0B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4BB2C76262D5BD5DAD0D5D28 /* LibNovelTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 48661ADCA15B54E048CF694C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FB32F3772CA09684F00497F3 /* APIClient.swift in Sources */, + A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */, + 5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */, + 9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */, + 749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */, + 7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */, + 08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */, + 2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */, + FEFB5FDC2424D22914458001 /* ChapterReaderView.swift in Sources */, + 2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */, + E2572692178FD17145FDAF77 /* Color+App.swift in Sources */, + F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */, + 94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */, + EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */, + C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */, + 032E049A4BB3CF0EA990C0CD /* LibNovelApp.swift in Sources */, + E1F564399D1325F6A1B2B84F /* LibraryView.swift in Sources */, + 3521DFD5FCBBED7B90368829 /* LibraryViewModel.swift in Sources */, + 0A52BC1CE71BED9E75D20D35 /* Models.swift in Sources */, + F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */, + BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */, + 64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */, + 58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */, + 367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 9FD4A50EB175FC09D6BFD28D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D039EDECDE3998D8534BB680 /* LibNovel */; + targetProxy = 698AC3AA533BC05C985595D0 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 428871329DC9E7B31FA1664B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel.tests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LibNovel.app/LibNovel"; + }; + name = Release; + }; + 49CBF0D367E562629E002A4B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel.tests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LibNovel.app/LibNovel"; + }; + name = Debug; + }; + 8098D4A97F989064EC71E5A1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + DEVELOPMENT_TEAM = GHZXC6FVMU; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LibNovel/Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 9C182367114E72FF84D54A2F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_PREVIEWS = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LIBNOVEL_BASE_URL = "https://v2.libnovel.kalekber.cc"; + MARKETING_VERSION = 1.0.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.10; + }; + name = Debug; + }; + D9977A0FA70F052FD0C126D3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + DEVELOPMENT_TEAM = GHZXC6FVMU; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LibNovel/Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + F9ED141CFB1E2EC6F5E9F089 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_PREVIEWS = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LIBNOVEL_BASE_URL = "https://v2.libnovel.kalekber.cc"; + MARKETING_VERSION = 1.0.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.10; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 29B2DE7267A3A4B2D89B32DA /* Build configuration list for PBXNativeTarget "LibNovel" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8098D4A97F989064EC71E5A1 /* Debug */, + D9977A0FA70F052FD0C126D3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 964FF85B62FA35E819BE7661 /* Build configuration list for PBXNativeTarget "LibNovelTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 49CBF0D367E562629E002A4B /* Debug */, + 428871329DC9E7B31FA1664B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9C182367114E72FF84D54A2F /* Debug */, + F9ED141CFB1E2EC6F5E9F089 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/onevcat/Kingfisher"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 8.0.0; + }; + }; + C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.4.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 09584EAB68A07B47F876A062 /* Kingfisher */ = { + isa = XCSwiftPackageProductDependency; + package = AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */; + productName = Kingfisher; + }; + 6313AB4B3A5464F647791174 /* MarkdownUI */ = { + isa = XCSwiftPackageProductDependency; + package = C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */; + productName = MarkdownUI; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = A10A669C0C8B43078C0FEE9F /* Project object */; +} diff --git a/ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..0c3d28f --- /dev/null +++ b/ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "18350c2bfa3935125b6f4e9817e7ed4508588c07142d420b8b8ee00640a57853", + "pins" : [ + { + "identity" : "kingfisher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Kingfisher", + "state" : { + "revision" : "c92b84898e34ab46ff0dad86c02a0acbe2d87008", + "version" : "8.8.0" + } + }, + { + "identity" : "networkimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/NetworkImage", + "state" : { + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe", + "version" : "0.7.1" + } + }, + { + "identity" : "swift-markdown-ui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swift-markdown-ui", + "state" : { + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" + } + } + ], + "version" : 3 +} diff --git a/ios/LibNovel/LibNovel.xcodeproj/xcshareddata/xcschemes/LibNovel.xcscheme b/ios/LibNovel/LibNovel.xcodeproj/xcshareddata/xcschemes/LibNovel.xcscheme new file mode 100644 index 0000000..32f5c74 --- /dev/null +++ b/ios/LibNovel/LibNovel.xcodeproj/xcshareddata/xcschemes/LibNovel.xcscheme @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/LibNovel/LibNovel/App/ContentView.swift b/ios/LibNovel/LibNovel/App/ContentView.swift new file mode 100644 index 0000000..fcf5237 --- /dev/null +++ b/ios/LibNovel/LibNovel/App/ContentView.swift @@ -0,0 +1,16 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var authStore: AuthStore + @EnvironmentObject var audioPlayer: AudioPlayerService + + var body: some View { + Group { + if authStore.isAuthenticated { + RootTabView() + } else { + AuthView() + } + } + } +} diff --git a/ios/LibNovel/LibNovel/App/LibNovelApp.swift b/ios/LibNovel/LibNovel/App/LibNovelApp.swift new file mode 100644 index 0000000..135a5b6 --- /dev/null +++ b/ios/LibNovel/LibNovel/App/LibNovelApp.swift @@ -0,0 +1,15 @@ +import SwiftUI + +@main +struct LibNovelApp: App { + @StateObject private var authStore = AuthStore() + @StateObject private var audioPlayer = AudioPlayerService() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(authStore) + .environmentObject(audioPlayer) + } + } +} diff --git a/ios/LibNovel/LibNovel/App/RootTabView.swift b/ios/LibNovel/LibNovel/App/RootTabView.swift new file mode 100644 index 0000000..1af25f6 --- /dev/null +++ b/ios/LibNovel/LibNovel/App/RootTabView.swift @@ -0,0 +1,63 @@ +import SwiftUI + +// MARK: - Root tab container with persistent mini-player overlay + +struct RootTabView: View { + @EnvironmentObject var authStore: AuthStore + @EnvironmentObject var audioPlayer: AudioPlayerService + + @State private var selectedTab: Tab = .home + @State private var showFullPlayer: Bool = false + + enum Tab: Hashable { + case home, library, browse, profile + } + + var body: some View { + ZStack(alignment: .bottom) { + TabView(selection: $selectedTab) { + HomeView() + .tabItem { Label("Home", systemImage: "house.fill") } + .tag(Tab.home) + + LibraryView() + .tabItem { Label("Library", systemImage: "books.vertical.fill") } + .tag(Tab.library) + + BrowseView() + .tabItem { Label("Discover", systemImage: "globe") } + .tag(Tab.browse) + + ProfileView() + .tabItem { Label("Profile", systemImage: "person.fill") } + .tag(Tab.profile) + } + // Push tab bar up when mini-player is visible + .safeAreaInset(edge: .bottom) { + if audioPlayer.isActive { + Color.clear.frame(height: 64) + } + } + + // Mini-player pinned above the tab bar + if audioPlayer.isActive { + MiniPlayerView(showFullPlayer: $showFullPlayer) + .padding(.bottom, tabBarHeight) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive) + } + } + .sheet(isPresented: $showFullPlayer) { + FullPlayerView() + } + } + + // 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 + } +} diff --git a/ios/LibNovel/LibNovel/Extensions/Color+App.swift b/ios/LibNovel/LibNovel/Extensions/Color+App.swift new file mode 100644 index 0000000..96d5aa4 --- /dev/null +++ b/ios/LibNovel/LibNovel/Extensions/Color+App.swift @@ -0,0 +1,11 @@ +import SwiftUI + +// MARK: - App accent color (amber — mirrors Tailwind amber-500 #f59e0b) +extension Color { + static let amber = Color(red: 0.96, green: 0.62, blue: 0.04) + static let amberLight = Color(red: 1.0, green: 0.84, blue: 0.40) +} + +extension ShapeStyle where Self == Color { + static var amber: Color { .amber } +} diff --git a/ios/LibNovel/LibNovel/Extensions/NavDestination.swift b/ios/LibNovel/LibNovel/Extensions/NavDestination.swift new file mode 100644 index 0000000..49a70f6 --- /dev/null +++ b/ios/LibNovel/LibNovel/Extensions/NavDestination.swift @@ -0,0 +1,8 @@ +import SwiftUI + +// MARK: - Navigation destination enum used across all tabs + +enum NavDestination: Hashable { + case book(String) // slug + case chapter(String, Int) // slug + chapter number +} diff --git a/ios/LibNovel/LibNovel/Models/Models.swift b/ios/LibNovel/LibNovel/Models/Models.swift new file mode 100644 index 0000000..a3ddcba --- /dev/null +++ b/ios/LibNovel/LibNovel/Models/Models.swift @@ -0,0 +1,259 @@ +import Foundation + +// MARK: - Book + +struct Book: Identifiable, Codable, Hashable { + let id: String + let slug: String + let title: String + let author: String + let cover: String + let status: String + let genres: [String] + let summary: String + let totalChapters: Int + let sourceURL: String + let ranking: Int + let metaUpdated: String + + enum CodingKeys: String, CodingKey { + case id, slug, title, author, cover, status, genres, summary + case totalChapters = "total_chapters" + case sourceURL = "source_url" + case ranking + case metaUpdated = "meta_updated" + } + + // PocketBase returns genres as either a JSON string array or a real array + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + slug = try container.decode(String.self, forKey: .slug) + title = try container.decode(String.self, forKey: .title) + author = try container.decode(String.self, forKey: .author) + cover = try container.decodeIfPresent(String.self, forKey: .cover) ?? "" + status = try container.decodeIfPresent(String.self, forKey: .status) ?? "" + totalChapters = try container.decodeIfPresent(Int.self, forKey: .totalChapters) ?? 0 + sourceURL = try container.decodeIfPresent(String.self, forKey: .sourceURL) ?? "" + ranking = try container.decodeIfPresent(Int.self, forKey: .ranking) ?? 0 + metaUpdated = try container.decodeIfPresent(String.self, forKey: .metaUpdated) ?? "" + summary = try container.decodeIfPresent(String.self, forKey: .summary) ?? "" + + // genres is sometimes a JSON-encoded string, sometimes a real array + if let arr = try? container.decode([String].self, forKey: .genres) { + genres = arr + } else if let str = try? container.decode(String.self, forKey: .genres), + let data = str.data(using: .utf8), + let arr = try? JSONDecoder().decode([String].self, from: data) { + genres = arr + } else { + genres = [] + } + } +} + +// MARK: - ChapterIndex + +struct ChapterIndex: Identifiable, Codable, Hashable { + let id: String + let slug: String + let number: Int + let title: String + let dateLabel: String + + enum CodingKeys: String, CodingKey { + case id, slug, number, title + case dateLabel = "date_label" + } +} + +struct ChapterIndexBrief: Codable, Hashable { + let number: Int + let title: String +} + +// MARK: - Progress + +struct ReadingProgress: Codable { + var id: String? + let sessionId: String + var userId: String? + let slug: String + var chapter: Int + var audioTime: Double? + let updated: String + + enum CodingKeys: String, CodingKey { + case id + case sessionId = "session_id" + case userId = "user_id" + case slug, chapter + case audioTime = "audio_time" + case updated + } +} + +// MARK: - User Settings + +struct UserSettings: Codable { + var id: String? + var autoNext: Bool + var voice: String + var speed: Double + + enum CodingKeys: String, CodingKey { + case id + case autoNext = "auto_next" + case voice, speed + } + + static let `default` = UserSettings(id: nil, autoNext: false, voice: "af_bella", speed: 1.0) +} + +// MARK: - User + +struct AppUser: Codable, Identifiable { + let id: String + let username: String + let role: String + let created: String + + var isAdmin: Bool { role == "admin" } + + 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) + role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user" + created = try c.decodeIfPresent(String.self, forKey: .created) ?? "" + } +} + +// MARK: - Browse / Novel Listing + +struct NovelListing: Identifiable, Hashable { + var id: String { slug.isEmpty ? url : slug } + let slug: String + let title: String + let cover: String + let rank: String + let rating: String + let chapters: String + let url: String + let author: String + let status: String + let genres: [String] +} + +// MARK: - Ranking + +struct RankingItem: Codable, Identifiable { + var id: String { slug } + let rank: Int + let slug: String + let title: String + let author: String + let cover: String + let status: String + let genres: [String] + let sourceURL: String + + enum CodingKeys: String, CodingKey { + case rank, slug, title, author, cover, status, genres + case sourceURL = "source_url" + } +} + +// MARK: - Home + +struct HomeData { + let continueReading: [ContinueReadingItem] + let recentlyUpdated: [Book] + let stats: HomeStats +} + +struct ContinueReadingItem: Identifiable { + var id: String { book.id } + let book: Book + let chapter: Int +} + +struct HomeStats: Codable { + let totalBooks: Int + let totalChapters: Int + let booksInProgress: Int +} + +// MARK: - Session + +struct UserSession: Codable, Identifiable { + let id: String + let userAgent: String + let ip: String + let createdAt: String + let lastSeen: String + var isCurrent: Bool + + enum CodingKeys: String, CodingKey { + case id + case userAgent = "user_agent" + case ip + case createdAt = "created_at" + case lastSeen = "last_seen" + case isCurrent = "is_current" + } +} + +// MARK: - Book Detail + +struct BookDetailData { + let book: Book + let chapters: [ChapterIndex] + let previewChapters: [PreviewChapter]? + let inLib: Bool + let saved: Bool + let lastChapter: Int? +} + +struct PreviewChapter: Codable, Identifiable { + var id: Int { number } + let number: Int + let title: String + let url: String +} + +// MARK: - Chapter Content + +struct ChapterContent { + let book: BookBrief + let chapter: ChapterIndex + let html: String + let voices: [String] + let prev: Int? + let next: Int? + let chapters: [ChapterIndexBrief] + let isPreview: Bool +} + +struct BookBrief: Codable { + let slug: String + let title: String + let cover: String +} + +// MARK: - Audio + +enum AudioStatus { + case idle, loading, generating, ready, error(String) +} + +enum NextPrefetchStatus { + case none, prefetching, prefetched, failed +} + +// MARK: - PocketBase list response + +struct PBList: Codable { + let items: [T] + let totalItems: Int +} diff --git a/ios/LibNovel/LibNovel/Networking/APIClient.swift b/ios/LibNovel/LibNovel/Networking/APIClient.swift new file mode 100644 index 0000000..3b8d4c2 --- /dev/null +++ b/ios/LibNovel/LibNovel/Networking/APIClient.swift @@ -0,0 +1,416 @@ +import Foundation + +// MARK: - API Client +// Communicates with the SvelteKit UI server (not directly with the Go scraper). +// The SvelteKit layer handles auth, PocketBase queries, and MinIO presigning. +// For the iOS app we talk to the same /api/* endpoints the web UI uses, +// so we reuse the exact same HMAC-cookie auth flow. + +actor APIClient { + static let shared = APIClient() + + private var baseURL: URL + private var authCookie: String? // raw "libnovel_auth=" header value + private var sessionId: String? // anon session id (UUID) + + // URLSession with persistent cookie storage + private let session: URLSession = { + let config = URLSessionConfiguration.default + config.httpCookieAcceptPolicy = .always + config.httpShouldSetCookies = true + config.httpCookieStorage = HTTPCookieStorage.shared + return URLSession(configuration: config) + }() + + private init() { + // Default: point at the UI server. Override via Settings bundle or compile flag. + let urlString = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String + ?? "https://v2.libnovel.kalekber.cc" + baseURL = URL(string: urlString)! + } + + // MARK: - Auth cookie management + + func setAuthCookie(_ value: String?) { + authCookie = value + if let value { + // Also inject into shared cookie storage so redirects carry the cookie + let cookieProps: [HTTPCookiePropertyKey: Any] = [ + .name: "libnovel_auth", + .value: value, + .domain: baseURL.host ?? "localhost", + .path: "/" + ] + if let cookie = HTTPCookie(properties: cookieProps) { + HTTPCookieStorage.shared.setCookie(cookie) + } + } else { + // Clear + let cookieStorage = HTTPCookieStorage.shared + cookieStorage.cookies(for: baseURL)?.forEach { cookieStorage.deleteCookie($0) } + } + } + + func setSessionId(_ id: String) { + sessionId = id + } + + // MARK: - Low-level request builder + + private func makeRequest(_ path: String, method: String = "GET", body: Encodable? = nil) throws -> URLRequest { + let url = baseURL.appendingPathComponent(path) + var req = URLRequest(url: url) + req.httpMethod = method + req.setValue("application/json", forHTTPHeaderField: "Accept") + if let body { + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONEncoder().encode(body) + } + return req + } + + // MARK: - Generic fetch + + func fetch(_ path: String, method: String = "GET", body: Encodable? = nil) async throws -> T { + let req = try makeRequest(path, method: method, body: body) + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + guard (200..<300).contains(http.statusCode) else { + let message = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)" + throw APIError.httpError(http.statusCode, message) + } + do { + return try JSONDecoder.iso8601.decode(T.self, from: data) + } catch { + throw APIError.decodingError(error) + } + } + + func fetchRaw(_ path: String, method: String = "GET", body: Encodable? = nil) async throws -> (Data, HTTPURLResponse) { + let req = try makeRequest(path, method: method, body: body) + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } + return (data, http) + } + + // MARK: - Auth + + struct LoginRequest: Encodable { + let username: String + let password: String + } + + struct LoginResponse: Decodable { + let token: String + let user: AppUser + } + + func login(username: String, password: String) async throws -> LoginResponse { + try await fetch("/api/auth/login", method: "POST", + body: LoginRequest(username: username, password: password)) + } + + func register(username: String, password: String) async throws -> LoginResponse { + try await fetch("/api/auth/register", method: "POST", + body: LoginRequest(username: username, password: password)) + } + + func logout() async throws { + let (_, _) = try await fetchRaw("/api/auth/logout", method: "POST") + await setAuthCookie(nil) + } + + // MARK: - Home + + func homeData() async throws -> HomeDataResponse { + try await fetch("/api/home") + } + + // MARK: - Library + + func library() async throws -> [LibraryItem] { + try await fetch("/api/library") + } + + func saveBook(slug: String) async throws { + let _: EmptyResponse = try await fetch("/api/library/\(slug)", method: "POST") + } + + func unsaveBook(slug: String) async throws { + let _: EmptyResponse = try await fetch("/api/library/\(slug)", method: "DELETE") + } + + // MARK: - Book Detail + + func bookDetail(slug: String) async throws -> BookDetailResponse { + try await fetch("/api/book/\(slug)") + } + + // MARK: - Chapter + + func chapterContent(slug: String, chapter: Int) async throws -> ChapterResponse { + try await fetch("/api/chapter/\(slug)/\(chapter)") + } + + // MARK: - Browse + + struct BrowseParams: Encodable { + let page: Int + let genre: String + let sort: String + let status: String + } + + func browse(page: Int, genre: String = "all", sort: String = "popular", status: String = "all") async throws -> BrowseResponse { + let query = "?page=\(page)&genre=\(genre)&sort=\(sort)&status=\(status)" + return try await fetch("/api/browse-page\(query)") + } + + func search(query: String) async throws -> SearchResponse { + let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query + return try await fetch("/api/search?q=\(encoded)") + } + + func ranking() async throws -> [RankingItem] { + try await fetch("/api/ranking") + } + + // MARK: - Progress + + func progress() async throws -> [ProgressEntry] { + try await fetch("/api/progress") + } + + func setProgress(slug: String, chapter: Int) async throws { + struct Body: Encodable { let chapter: Int } + let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "POST", body: Body(chapter: chapter)) + } + + 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)") + return r.audioTime + } + + func setAudioTime(slug: String, chapter: Int, time: Double) async throws { + struct Body: Encodable { let slug: String; let chapter: Int; let audioTime: Double; enum CodingKeys: String, CodingKey { case slug, chapter; case audioTime = "audio_time" } } + let _: EmptyResponse = try await fetch("/api/progress/audio-time", method: "PATCH", body: Body(slug: slug, chapter: chapter, audioTime: time)) + } + + // MARK: - Audio + + func triggerAudio(slug: String, chapter: Int, voice: String, speed: Double) async throws -> AudioGenerateResponse { + struct Body: Encodable { let voice: String; let speed: Double } + return try await fetch("/api/audio/\(slug)/\(chapter)", method: "POST", body: Body(voice: voice, speed: speed)) + } + + func presignAudio(slug: String, chapter: Int, voice: String) async throws -> String { + struct Response: Decodable { let url: String } + let r: Response = try await fetch("/api/presign/audio?slug=\(slug)&chapter=\(chapter)&voice=\(voice)") + return r.url + } + + func presignVoiceSample(voice: String) async throws -> String { + struct Response: Decodable { let url: String } + let r: Response = try await fetch("/api/presign/voice-sample?voice=\(voice)") + return r.url + } + + func voices() async throws -> [String] { + struct Response: Decodable { let voices: [String] } + let r: Response = try await fetch("/api/voices") + return r.voices + } + + // MARK: - Settings + + func settings() async throws -> UserSettings { + try await fetch("/api/settings") + } + + func updateSettings(_ settings: UserSettings) async throws { + let _: EmptyResponse = try await fetch("/api/settings", method: "PUT", body: settings) + } + + // MARK: - Sessions + + func sessions() async throws -> [UserSession] { + try await fetch("/api/sessions") + } + + func revokeSession(id: String) async throws { + let _: EmptyResponse = try await fetch("/api/sessions/\(id)", method: "DELETE") + } +} + +// MARK: - Response types + +struct HomeDataResponse: Decodable { + struct ContinueItem: Decodable { + let book: Book + let chapter: Int + } + let continueReading: [ContinueItem] + let recentlyUpdated: [Book] + let stats: HomeStats + + enum CodingKeys: String, CodingKey { + case continueReading = "continue_reading" + case recentlyUpdated = "recently_updated" + case stats + } +} + +struct LibraryItem: Decodable, Identifiable { + var id: String { book.id } + let book: Book + let savedAt: String + let lastChapter: Int? + + enum CodingKeys: String, CodingKey { + case book + case savedAt = "saved_at" + case lastChapter = "last_chapter" + } +} + +struct BookDetailResponse: Decodable { + let book: Book + let chapters: [ChapterIndex] + let previewChapters: [PreviewChapter]? + let inLib: Bool + let saved: Bool + let lastChapter: Int? + + enum CodingKeys: String, CodingKey { + case book, chapters + case previewChapters = "preview_chapters" + case inLib = "in_lib" + case saved + case lastChapter = "last_chapter" + } +} + +struct ChapterResponse: Decodable { + let book: BookBrief + let chapter: ChapterIndex + let html: String + let voices: [String] + let prev: Int? + let next: Int? + let chapters: [ChapterIndexBrief] + let isPreview: Bool + + enum CodingKeys: String, CodingKey { + case book, chapter, html, voices, prev, next, chapters + case isPreview = "is_preview" + } +} + +struct BrowseResponse: Decodable { + let novels: [BrowseNovel] + let page: Int + let hasNext: Bool + + enum CodingKeys: String, CodingKey { + case novels, page + case hasNext = "has_next" + } +} + +struct BrowseNovel: Decodable, Identifiable, Hashable { + var id: String { slug.isEmpty ? url : slug } + let slug: String + let title: String + let cover: String + let rank: String + let rating: String + let chapters: String + let url: String + let author: String + let status: String + let genres: [String] + + enum CodingKeys: String, CodingKey { + case slug, title, cover, rank, rating, chapters, url, author, status, genres + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? "" + title = try c.decode(String.self, forKey: .title) + cover = try c.decodeIfPresent(String.self, forKey: .cover) ?? "" + rank = try c.decodeIfPresent(String.self, forKey: .rank) ?? "" + rating = try c.decodeIfPresent(String.self, forKey: .rating) ?? "" + chapters = try c.decodeIfPresent(String.self, forKey: .chapters) ?? "" + url = try c.decodeIfPresent(String.self, forKey: .url) ?? "" + author = try c.decodeIfPresent(String.self, forKey: .author) ?? "" + status = try c.decodeIfPresent(String.self, forKey: .status) ?? "" + genres = try c.decodeIfPresent([String].self, forKey: .genres) ?? [] + } +} + +struct SearchResponse: Decodable { + let results: [BrowseNovel] + let localCount: Int + let remoteCount: Int + + enum CodingKeys: String, CodingKey { + case results + case localCount = "local_count" + case remoteCount = "remote_count" + } +} + +struct AudioGenerateResponse: Decodable { + let status: String // "generating" | "ready" | "error" + let url: String? + let message: String? +} + +struct ProgressEntry: Decodable, Identifiable { + var id: String { slug } + let slug: String + let chapter: Int + let audioTime: Double? + let updated: String + + enum CodingKeys: String, CodingKey { + case slug, chapter, updated + case audioTime = "audio_time" + } +} + +struct EmptyResponse: Decodable {} + +// MARK: - API Error + +enum APIError: LocalizedError { + case invalidResponse + case httpError(Int, String) + case decodingError(Error) + case unauthorized + case networkError(Error) + + var errorDescription: String? { + switch self { + case .invalidResponse: return "Invalid server response" + case .httpError(let code, let msg): return "HTTP \(code): \(msg)" + case .decodingError(let e): return "Decode error: \(e.localizedDescription)" + case .unauthorized: return "Not authenticated" + case .networkError(let e): return e.localizedDescription + } + } +} + +// MARK: - JSONDecoder helper + +extension JSONDecoder { + static let iso8601: JSONDecoder = { + let d = JSONDecoder() + d.dateDecodingStrategy = .iso8601 + return d + }() +} diff --git a/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..e66f937 --- /dev/null +++ b/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,12 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { "alpha": "1.000", "blue": "0.588", "green": "0.467", "red": "1.000" } + }, + "idiom": "universal" + } + ], + "info": { "author": "xcode", "version": 1 } +} diff --git a/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..b121e3b --- /dev/null +++ b/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images": [ + { + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/LibNovel/LibNovel/Resources/Assets.xcassets/Contents.json b/ios/LibNovel/LibNovel/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..319a86b --- /dev/null +++ b/ios/LibNovel/LibNovel/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,3 @@ +{ + "info": { "author": "xcode", "version": 1 } +} diff --git a/ios/LibNovel/LibNovel/Resources/Info.plist b/ios/LibNovel/LibNovel/Resources/Info.plist new file mode 100644 index 0000000..5239f0a --- /dev/null +++ b/ios/LibNovel/LibNovel/Resources/Info.plist @@ -0,0 +1,43 @@ + + + + + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleName + LibNovel + CFBundleDisplayName + LibNovel + CFBundleIdentifier + cc.kalekber.libnovel + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundlePackageType + APPL + LSRequiresIPhoneOS + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UILaunchScreen + + UIBackgroundModes + + audio + + LIBNOVEL_BASE_URL + $(LIBNOVEL_BASE_URL) + + diff --git a/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift b/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift new file mode 100644 index 0000000..3e619ca --- /dev/null +++ b/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift @@ -0,0 +1,368 @@ +import Foundation +import AVFoundation +import MediaPlayer +import Combine + +// MARK: - AudioPlayerService +// Central singleton that owns AVPlayer, drives audio state, handles lock-screen +// controls (NowPlayingInfoCenter + MPRemoteCommandCenter), and pre-fetches the +// next chapter audio — mirroring the web AudioStore. + +@MainActor +final class AudioPlayerService: ObservableObject { + + // MARK: - Published state (mirrors web AudioStore fields) + + @Published var slug: String = "" + @Published var chapter: Int = 0 + @Published var chapterTitle: String = "" + @Published var bookTitle: String = "" + @Published var coverURL: String = "" + @Published var voice: String = "af_bella" + @Published var speed: Double = 1.0 + @Published var chapters: [ChapterIndexBrief] = [] + + @Published var status: AudioPlayerStatus = .idle + @Published var audioURL: String = "" + @Published var errorMessage: String = "" + @Published var generationProgress: Double = 0 + + @Published var currentTime: Double = 0 + @Published var duration: Double = 0 + @Published var isPlaying: Bool = false + + @Published var autoNext: Bool = false + @Published var nextChapter: Int? = nil + + @Published var nextPrefetchStatus: NextPrefetchStatus = .none + @Published var nextAudioURL: String = "" + @Published var nextProgress: Double = 0 + @Published var nextPrefetchedChapter: Int? = nil + + var isActive: Bool { + switch status { + case .idle: return false + default: return true + } + } + + // MARK: - Private + + private var player: AVPlayer? + private var playerItem: AVPlayerItem? + private var timeObserver: Any? + private var statusObserver: AnyCancellable? + private var finishObserver: AnyCancellable? + private var generationTask: Task? + private var prefetchTask: Task? + + // MARK: - Init + + init() { + configureAudioSession() + setupRemoteCommandCenter() + } + + // MARK: - Public API + + /// Load audio for a specific chapter. Triggers TTS generation if not cached. + func load(slug: String, chapter: Int, chapterTitle: String, + bookTitle: String, coverURL: String, voice: String, speed: Double, + chapters: [ChapterIndexBrief], nextChapter: Int?) { + // Cancel any in-flight generation + generationTask?.cancel() + prefetchTask?.cancel() + stop() + + self.slug = slug + self.chapter = chapter + self.chapterTitle = chapterTitle + self.bookTitle = bookTitle + self.coverURL = coverURL + self.voice = voice + self.speed = speed + self.chapters = chapters + self.nextChapter = nextChapter + self.nextPrefetchStatus = .none + self.nextAudioURL = "" + self.nextPrefetchedChapter = nil + + status = .loading + generationProgress = 0 + + generationTask = Task { await generateAudio() } + } + + func play() { + player?.play() + player?.rate = Float(speed) + isPlaying = true + updateNowPlaying() + } + + func pause() { + player?.pause() + isPlaying = false + updateNowPlaying() + } + + func togglePlayPause() { + isPlaying ? pause() : play() + } + + func seek(to seconds: Double) { + let time = CMTime(seconds: seconds, preferredTimescale: 600) + player?.seek(to: time) + currentTime = seconds + updateNowPlaying() + } + + func skip(by seconds: Double) { + seek(to: max(0, min(currentTime + seconds, duration))) + } + + func setSpeed(_ newSpeed: Double) { + speed = newSpeed + if isPlaying { player?.rate = Float(newSpeed) } + updateNowPlaying() + } + + func stop() { + player?.pause() + teardownPlayer() + isPlaying = false + currentTime = 0 + duration = 0 + status = .idle + } + + // MARK: - Audio generation loop (mirrors web polling pattern) + + private func generateAudio() async { + guard !slug.isEmpty, chapter > 0 else { return } + do { + var pollCount = 0 + while !Task.isCancelled { + let response = try await APIClient.shared.triggerAudio(slug: slug, chapter: chapter, voice: voice, speed: speed) + switch response.status { + case "ready": + if let url = response.url { + await MainActor.run { + self.audioURL = url + self.status = .ready + self.generationProgress = 100 + } + await playURL(url) + await prefetchNext() + } + return + case "generating": + await MainActor.run { + self.status = .generating + // Simulate progress ramp (same trick as the web UI) + self.generationProgress = min(95, Double(pollCount) * 8) + } + pollCount += 1 + try await Task.sleep(for: .seconds(2)) + case "error": + await MainActor.run { + self.status = .error(response.message ?? "Unknown error") + } + return + default: + break + } + } + } catch is CancellationError { + // Cancelled — no-op + } catch { + await MainActor.run { + self.status = .error(error.localizedDescription) + self.errorMessage = error.localizedDescription + } + } + } + + // MARK: - Prefetch next chapter + + private func prefetchNext() async { + guard autoNext, let next = nextChapter, !Task.isCancelled else { return } + nextPrefetchStatus = .prefetching + nextPrefetchedChapter = next + do { + var pollCount = 0 + while !Task.isCancelled { + let response = try await APIClient.shared.triggerAudio(slug: slug, chapter: next, voice: voice, speed: speed) + switch response.status { + case "ready": + if let url = response.url { + nextAudioURL = url + nextPrefetchStatus = .prefetched + } + return + case "generating": + nextProgress = min(95, Double(pollCount) * 8) + pollCount += 1 + try await Task.sleep(for: .seconds(2)) + case "error": + nextPrefetchStatus = .failed + return + default: + break + } + } + } catch { + nextPrefetchStatus = .failed + } + } + + // MARK: - AVPlayer management + + private func playURL(_ urlString: String) async { + guard let url = URL(string: urlString) else { return } + teardownPlayer() + let item = AVPlayerItem(url: url) + playerItem = item + player = AVPlayer(playerItem: item) + player?.rate = Float(speed) + + // Observe playback time + timeObserver = player?.addPeriodicTimeObserver( + forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), + queue: .main + ) { [weak self] time in + guard let self else { return } + Task { @MainActor in + self.currentTime = time.seconds + if let dur = self.playerItem?.duration.seconds, dur.isFinite, dur > 0 { + self.duration = dur + } + } + } + + // Observe when playback ends + finishObserver = NotificationCenter.default + .publisher(for: AVPlayerItem.didPlayToEndTimeNotification, object: item) + .sink { [weak self] _ in + Task { @MainActor in + self?.handlePlaybackFinished() + } + } + + player?.play() + isPlaying = true + updateNowPlaying() + } + + private func teardownPlayer() { + if let observer = timeObserver { player?.removeTimeObserver(observer) } + timeObserver = nil + finishObserver = nil + player = nil + playerItem = nil + } + + private func handlePlaybackFinished() { + isPlaying = false + if autoNext, let next = nextChapter { + // The chapter view model listens for this signal to navigate + NotificationCenter.default.post(name: .audioDidFinishChapter, + object: nil, + userInfo: ["next": next]) + } + } + + // MARK: - Audio Session + + private func configureAudioSession() { + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + // Non-fatal + } + } + + // MARK: - Lock Screen / Control Center (NowPlayingInfoCenter) + + private func setupRemoteCommandCenter() { + let center = MPRemoteCommandCenter.shared() + center.playCommand.addTarget { [weak self] _ in + self?.play() + return .success + } + center.pauseCommand.addTarget { [weak self] _ in + self?.pause() + return .success + } + center.togglePlayPauseCommand.addTarget { [weak self] _ in + self?.togglePlayPause() + return .success + } + center.skipForwardCommand.preferredIntervals = [30] + center.skipForwardCommand.addTarget { [weak self] _ in + self?.skip(by: 30) + return .success + } + center.skipBackwardCommand.preferredIntervals = [15] + center.skipBackwardCommand.addTarget { [weak self] _ in + self?.skip(by: -15) + return .success + } + center.changePlaybackPositionCommand.addTarget { [weak self] event in + if let e = event as? MPChangePlaybackPositionCommandEvent { + self?.seek(to: e.positionTime) + } + return .success + } + } + + private func updateNowPlaying() { + var info: [String: Any] = [ + MPMediaItemPropertyTitle: chapterTitle.isEmpty ? "Chapter \(chapter)" : chapterTitle, + MPMediaItemPropertyArtist: bookTitle, + MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime, + MPMediaItemPropertyPlaybackDuration: duration, + MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? speed : 0.0 + ] + + // Cover art (async download) + if !coverURL.isEmpty, let url = URL(string: coverURL) { + URLSession.shared.dataTask(with: url) { data, _, _ in + if let data, let image = UIImage(data: data) { + let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } + var updated = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] + updated[MPMediaItemPropertyArtwork] = artwork + MPNowPlayingInfoCenter.default().nowPlayingInfo = updated + } + }.resume() + } + + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + } +} + +// MARK: - Supporting types + +enum AudioPlayerStatus: Equatable { + case idle + case loading + case generating + case ready + case error(String) + + static func == (lhs: AudioPlayerStatus, rhs: AudioPlayerStatus) -> Bool { + switch (lhs, rhs) { + case (.idle, .idle), (.loading, .loading), (.generating, .generating), (.ready, .ready): + return true + case (.error(let a), .error(let b)): + return a == b + default: + return false + } + } +} + +extension Notification.Name { + static let audioDidFinishChapter = Notification.Name("audioDidFinishChapter") +} diff --git a/ios/LibNovel/LibNovel/Services/AuthStore.swift b/ios/LibNovel/LibNovel/Services/AuthStore.swift new file mode 100644 index 0000000..a3973e7 --- /dev/null +++ b/ios/LibNovel/LibNovel/Services/AuthStore.swift @@ -0,0 +1,139 @@ +import Foundation +import Combine + +// MARK: - AuthStore +// Owns the authenticated user, the HMAC auth token, and user settings. +// Persists the token to Keychain so the user stays logged in across launches. + +@MainActor +final class AuthStore: ObservableObject { + @Published var user: AppUser? + @Published var settings: UserSettings = .default + @Published var isLoading: Bool = false + @Published var error: String? + + var isAuthenticated: Bool { user != nil } + + private let keychainKey = "libnovel_auth_token" + + init() { + // Restore token from Keychain and validate it on launch + if let token = loadToken() { + Task { await validateToken(token) } + } + } + + // MARK: - Login / Register + + func login(username: String, password: String) async { + isLoading = true + error = nil + do { + let response = try await APIClient.shared.login(username: username, password: password) + await APIClient.shared.setAuthCookie(response.token) + saveToken(response.token) + user = response.user + await loadSettings() + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + + func register(username: String, password: String) async { + isLoading = true + error = nil + do { + let response = try await APIClient.shared.register(username: username, password: password) + await APIClient.shared.setAuthCookie(response.token) + saveToken(response.token) + user = response.user + await loadSettings() + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + + func logout() async { + do { + try await APIClient.shared.logout() + } catch { + // Best-effort; clear local state regardless + } + clearToken() + user = nil + settings = .default + } + + // MARK: - Settings + + func loadSettings() async { + do { + settings = try await APIClient.shared.settings() + } catch { + // Use defaults if settings endpoint fails + } + } + + func saveSettings(_ updated: UserSettings) async { + do { + try await APIClient.shared.updateSettings(updated) + settings = updated + } catch { + self.error = error.localizedDescription + } + } + + // MARK: - Token validation (on cold launch) + + private func validateToken(_ token: String) async { + await APIClient.shared.setAuthCookie(token) + // Use /api/auth/me to restore the user record and confirm the token is still valid + do { + async let me: AppUser = APIClient.shared.fetch("/api/auth/me") + async let s: UserSettings = APIClient.shared.settings() + let (restoredUser, restoredSettings) = try await (me, s) + user = restoredUser + settings = restoredSettings + } catch let e as APIError { + if case .httpError(let code, _) = e, code == 401 { + clearToken() + } + } catch {} + } + + // MARK: - Keychain helpers + + private func saveToken(_ token: String) { + let data = Data(token.utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: keychainKey, + kSecValueData as String: data + ] + SecItemDelete(query as CFDictionary) + SecItemAdd(query as CFDictionary, nil) + } + + private func loadToken() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: keychainKey, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + private func clearToken() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: keychainKey + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/ios/LibNovel/LibNovel/ViewModels/BookDetailViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/BookDetailViewModel.swift new file mode 100644 index 0000000..37051c4 --- /dev/null +++ b/ios/LibNovel/LibNovel/ViewModels/BookDetailViewModel.swift @@ -0,0 +1,46 @@ +import Foundation + +@MainActor +final class BookDetailViewModel: ObservableObject { + let slug: String + + @Published var book: Book? + @Published var chapters: [ChapterIndex] = [] + @Published var saved: Bool = false + @Published var lastChapter: Int? + @Published var isLoading = false + @Published var chaptersLoading = false + @Published var error: String? + + init(slug: String) { + self.slug = slug + } + + func load() async { + isLoading = true + error = nil + do { + let detail = try await APIClient.shared.bookDetail(slug: slug) + book = detail.book + chapters = detail.chapters + saved = detail.saved + lastChapter = detail.lastChapter + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + + func toggleSaved() async { + do { + if saved { + try await APIClient.shared.unsaveBook(slug: slug) + } else { + try await APIClient.shared.saveBook(slug: slug) + } + saved.toggle() + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/ios/LibNovel/LibNovel/ViewModels/BrowseViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/BrowseViewModel.swift new file mode 100644 index 0000000..0a2864a --- /dev/null +++ b/ios/LibNovel/LibNovel/ViewModels/BrowseViewModel.swift @@ -0,0 +1,69 @@ +import Foundation + +@MainActor +final class BrowseViewModel: ObservableObject { + @Published var novels: [BrowseNovel] = [] + @Published var sort: String = "popular" + @Published var genre: String = "all" + @Published var status: String = "all" + @Published var searchQuery: String = "" + @Published var isLoading = false + @Published var hasNext = false + @Published var error: String? + + private var currentPage = 1 + private var isSearchMode = false + + func loadFirstPage() async { + currentPage = 1 + novels = [] + isSearchMode = false + await loadPage(1) + } + + func loadNextPage() async { + guard hasNext, !isLoading else { return } + await loadPage(currentPage + 1) + } + + func search() async { + guard !searchQuery.isEmpty else { await loadFirstPage(); return } + isLoading = true + isSearchMode = true + novels = [] + error = nil + do { + let result = try await APIClient.shared.search(query: searchQuery) + novels = result.results + hasNext = false + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + + func clearSearch() { + searchQuery = "" + Task { await loadFirstPage() } + } + + private func loadPage(_ page: Int) async { + isLoading = true + error = nil + do { + let result = try await APIClient.shared.browse( + page: page, genre: genre, sort: sort, status: status + ) + if page == 1 { + novels = result.novels + } else { + novels.append(contentsOf: result.novels) + } + hasNext = result.hasNext + currentPage = page + } catch { + self.error = error.localizedDescription + } + isLoading = false + } +} diff --git a/ios/LibNovel/LibNovel/ViewModels/ChapterReaderViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/ChapterReaderViewModel.swift new file mode 100644 index 0000000..56ff569 --- /dev/null +++ b/ios/LibNovel/LibNovel/ViewModels/ChapterReaderViewModel.swift @@ -0,0 +1,53 @@ +import Foundation + +@MainActor +final class ChapterReaderViewModel: ObservableObject { + let slug: String + let chapter: Int + + @Published var content: ChapterResponse? + @Published var isLoading = false + @Published var error: String? + @Published var navigateTo: Int? // set to trigger navigation to next chapter + + init(slug: String, chapter: Int) { + self.slug = slug + self.chapter = chapter + } + + func load() async { + isLoading = true + error = nil + do { + content = try await APIClient.shared.chapterContent(slug: slug, chapter: chapter) + // Record reading progress + try? await APIClient.shared.setProgress(slug: slug, chapter: chapter) + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + + func toggleAudio(audioPlayer: AudioPlayerService, settings: UserSettings) { + guard let content else { return } + + let isCurrent = audioPlayer.slug == slug && audioPlayer.chapter == chapter + + if isCurrent { + audioPlayer.togglePlayPause() + } else { + let nextChapter: Int? = content.next + audioPlayer.load( + slug: slug, + chapter: chapter, + chapterTitle: content.chapter.title, + bookTitle: content.book.title, + coverURL: content.book.cover, + voice: settings.voice, + speed: settings.speed, + chapters: content.chapters, + nextChapter: nextChapter + ) + } + } +} diff --git a/ios/LibNovel/LibNovel/ViewModels/HomeViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/HomeViewModel.swift new file mode 100644 index 0000000..55583c9 --- /dev/null +++ b/ios/LibNovel/LibNovel/ViewModels/HomeViewModel.swift @@ -0,0 +1,26 @@ +import Foundation + +@MainActor +final class HomeViewModel: ObservableObject { + @Published var continueReading: [ContinueReadingItem] = [] + @Published var recentlyUpdated: [Book] = [] + @Published var stats: HomeStats? + @Published var isLoading = false + @Published var error: String? + + func load() async { + isLoading = true + error = nil + do { + let data = try await APIClient.shared.homeData() + continueReading = data.continueReading.map { + ContinueReadingItem(book: $0.book, chapter: $0.chapter) + } + recentlyUpdated = data.recentlyUpdated + stats = data.stats + } catch { + self.error = error.localizedDescription + } + isLoading = false + } +} diff --git a/ios/LibNovel/LibNovel/ViewModels/LibraryViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/LibraryViewModel.swift new file mode 100644 index 0000000..aed85b3 --- /dev/null +++ b/ios/LibNovel/LibNovel/ViewModels/LibraryViewModel.swift @@ -0,0 +1,19 @@ +import Foundation + +@MainActor +final class LibraryViewModel: ObservableObject { + @Published var items: [LibraryItem] = [] + @Published var isLoading = false + @Published var error: String? + + func load() async { + isLoading = true + error = nil + do { + items = try await APIClient.shared.library() + } catch { + self.error = error.localizedDescription + } + isLoading = false + } +} diff --git a/ios/LibNovel/LibNovel/ViewModels/ProfileViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/ProfileViewModel.swift new file mode 100644 index 0000000..adde55f --- /dev/null +++ b/ios/LibNovel/LibNovel/ViewModels/ProfileViewModel.swift @@ -0,0 +1,40 @@ +import Foundation + +@MainActor +final class ProfileViewModel: ObservableObject { + @Published var sessions: [UserSession] = [] + @Published var voices: [String] = [] + @Published var sessionsLoading = false + @Published var error: String? + + func loadSessions() async { + sessionsLoading = true + do { + sessions = try await APIClient.shared.sessions() + } catch { + self.error = error.localizedDescription + } + sessionsLoading = false + } + + func loadVoices() async { + guard voices.isEmpty else { return } + do { + voices = try await APIClient.shared.voices() + } catch { + // Use hardcoded fallback — same as Go server helpers.go + voices = ["af_bella", "af_sky", "af_sarah", "af_nicole", + "am_adam", "am_michael", "bf_emma", "bf_isabella", + "bm_george", "bm_lewis"] + } + } + + func revokeSession(id: String) async { + do { + try await APIClient.shared.revokeSession(id: id) + sessions.removeAll { $0.id == id } + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/ios/LibNovel/LibNovel/Views/Auth/AuthView.swift b/ios/LibNovel/LibNovel/Views/Auth/AuthView.swift new file mode 100644 index 0000000..7f1230c --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Auth/AuthView.swift @@ -0,0 +1,123 @@ +import SwiftUI + +struct AuthView: View { + @EnvironmentObject var authStore: AuthStore + @State private var mode: Mode = .login + @State private var username: String = "" + @State private var password: String = "" + @State private var confirmPassword: String = "" + @FocusState private var focusedField: Field? + + enum Mode { case login, register } + enum Field { case username, password, confirmPassword } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // Logo / header + VStack(spacing: 8) { + Image(systemName: "books.vertical.fill") + .font(.system(size: 56)) + .foregroundStyle(.amber) + Text("LibNovel") + .font(.largeTitle.bold()) + } + .padding(.top, 60) + .padding(.bottom, 40) + + // Tab switcher + Picker("Mode", selection: $mode) { + Text("Sign In").tag(Mode.login) + Text("Create Account").tag(Mode.register) + } + .pickerStyle(.segmented) + .padding(.horizontal, 24) + .padding(.bottom, 32) + + // Form + VStack(spacing: 16) { + TextField("Username", text: $username) + .textFieldStyle(.roundedBorder) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($focusedField, equals: .username) + .submitLabel(.next) + .onSubmit { focusedField = .password } + + SecureField("Password", text: $password) + .textFieldStyle(.roundedBorder) + .focused($focusedField, equals: .password) + .submitLabel(mode == .register ? .next : .go) + .onSubmit { + if mode == .register { focusedField = .confirmPassword } + else { submit() } + } + + if mode == .register { + SecureField("Confirm Password", text: $confirmPassword) + .textFieldStyle(.roundedBorder) + .focused($focusedField, equals: .confirmPassword) + .submitLabel(.go) + .onSubmit { submit() } + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding(.horizontal, 24) + .animation(.easeInOut(duration: 0.2), value: mode) + + if let error = authStore.error { + Text(error) + .font(.footnote) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + .padding(.top, 8) + } + + Button(action: submit) { + Group { + if authStore.isLoading { + ProgressView() + .progressViewStyle(.circular) + .tint(.white) + } else { + Text(mode == .login ? "Sign In" : "Create Account") + .fontWeight(.semibold) + } + } + .frame(maxWidth: .infinity) + .frame(height: 50) + } + .buttonStyle(.borderedProminent) + .tint(.amber) + .padding(.horizontal, 24) + .padding(.top, 24) + .disabled(authStore.isLoading || !formIsValid) + + Spacer() + } + .navigationBarHidden(true) + } + .onChange(of: mode) { _, _ in + authStore.error = nil + confirmPassword = "" + } + } + + private var formIsValid: Bool { + let base = !username.isEmpty && password.count >= 4 + if mode == .register { return base && password == confirmPassword } + return base + } + + private func submit() { + focusedField = nil + Task { + if mode == .login { + await authStore.login(username: username, password: password) + } else { + await authStore.register(username: username, password: password) + } + } + } +} diff --git a/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift b/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift new file mode 100644 index 0000000..f346006 --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift @@ -0,0 +1,220 @@ +import SwiftUI + +struct BookDetailView: View { + let slug: String + @StateObject private var vm: BookDetailViewModel + @EnvironmentObject var authStore: AuthStore + @EnvironmentObject var audioPlayer: AudioPlayerService + @State private var summaryExpanded = false + @State private var chapterPage = 0 + private let pageSize = 100 + + init(slug: String) { + self.slug = slug + _vm = StateObject(wrappedValue: BookDetailViewModel(slug: slug)) + } + + var body: some View { + ScrollView { + if vm.isLoading { + ProgressView().frame(maxWidth: .infinity).padding(.top, 80) + } else if let book = vm.book { + VStack(alignment: .leading, spacing: 0) { + heroSection(book: book) + Divider().padding(.vertical, 8) + chapterSection(book: book) + } + } + } + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .toolbar { bookmarkButton } + .task { await vm.load() } + .alert("Error", isPresented: .constant(vm.error != nil)) { + Button("OK") { vm.error = nil } + } message: { Text(vm.error ?? "") } + } + + // MARK: - Hero + + @ViewBuilder + private func heroSection(book: Book) -> some View { + ZStack(alignment: .bottom) { + // Blurred cover background + AsyncCoverImage(url: book.cover) + .frame(maxWidth: .infinity) + .frame(height: 260) + .blur(radius: 20) + .clipped() + .overlay(Color.black.opacity(0.45)) + + HStack(alignment: .bottom, spacing: 14) { + AsyncCoverImage(url: book.cover) + .frame(width: 110, height: 160) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .shadow(radius: 8) + + VStack(alignment: .leading, spacing: 6) { + Text(book.title) + .font(.headline) + .foregroundStyle(.white) + .lineLimit(3) + Text(book.author) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.8)) + HStack { + TagChip(label: book.status).colorScheme(.dark) + ForEach(book.genres.prefix(2), id: \.self) { + TagChip(label: $0).colorScheme(.dark) + } + } + } + Spacer(minLength: 0) + } + .padding(.horizontal) + .padding(.bottom, 16) + } + + // Summary + VStack(alignment: .leading, spacing: 8) { + Text(book.summary) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(summaryExpanded ? nil : 4) + if book.summary.count > 200 { + Button(summaryExpanded ? "Less" : "More") { + withAnimation { summaryExpanded.toggle() } + } + .font(.caption.bold()) + .foregroundStyle(.amber) + } + } + .padding() + + // CTA buttons + HStack(spacing: 10) { + if let last = vm.lastChapter, last > 0 { + NavigationLink(value: NavDestination.chapter(slug, last)) { + Label("Continue Ch.\(last)", systemImage: "play.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.amber) + + NavigationLink(value: NavDestination.chapter(slug, 1)) { + Label("From Ch.1", systemImage: "arrow.counterclockwise") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.secondary) + } else { + NavigationLink(value: NavDestination.chapter(slug, 1)) { + Label("Start Reading", systemImage: "book.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.amber) + } + } + .padding(.horizontal) + .padding(.bottom, 8) + } + + // MARK: - Chapter list + + @ViewBuilder + private func chapterSection(book: Book) -> some View { + let chapters = vm.chapters + let total = chapters.count + let start = chapterPage * pageSize + let end = min(start + pageSize, total) + let pageChapters = Array(chapters[start.. 0 { + Text("\(start + 1)–\(end) of \(total)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal) + .padding(.vertical, 10) + + if vm.chaptersLoading { + ProgressView().frame(maxWidth: .infinity).padding() + } else { + ForEach(pageChapters) { ch in + NavigationLink(value: NavDestination.chapter(slug, ch.number)) { + ChapterRow(chapter: ch, isCurrent: ch.number == vm.lastChapter) + } + .buttonStyle(.plain) + Divider().padding(.leading) + } + } + + // Pagination + if total > pageSize { + HStack { + Button("Previous") { chapterPage -= 1 } + .disabled(chapterPage == 0) + Spacer() + Button("Next") { chapterPage += 1 } + .disabled(end >= total) + } + .buttonStyle(.bordered) + .padding() + } + } + } + + // MARK: - Toolbar bookmark + + @ToolbarContentBuilder + private var bookmarkButton: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await vm.toggleSaved() } + } label: { + Image(systemName: vm.saved ? "bookmark.fill" : "bookmark") + .foregroundStyle(vm.saved ? .amber : .primary) + } + } + } +} + +private struct ChapterRow: View { + let chapter: ChapterIndex + let isCurrent: Bool + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Chapter \(chapter.number)") + .font(.subheadline) + .fontWeight(isCurrent ? .bold : .regular) + .foregroundStyle(isCurrent ? .amber : .primary) + if !chapter.title.isEmpty && chapter.title != "Chapter \(chapter.number)" { + Text(chapter.title) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer() + if !chapter.dateLabel.isEmpty { + Text(chapter.dateLabel) + .font(.caption2) + .foregroundStyle(.tertiary) + } + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .padding(.horizontal) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } +} diff --git a/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift b/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift new file mode 100644 index 0000000..dcb9f47 --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift @@ -0,0 +1,198 @@ +import SwiftUI + +struct BrowseView: View { + @StateObject private var vm = BrowseViewModel() + @State private var showFilters = false + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // Search bar + HStack { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("Search novels...", text: $vm.searchQuery) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.search) + .onSubmit { Task { await vm.search() } } + if !vm.searchQuery.isEmpty { + Button { vm.clearSearch() } label: { + Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) + } + } + } + .padding(10) + .background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal) + .padding(.vertical, 8) + + // Filter chips row + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + FilterChip(label: "Sort: \(vm.sort.capitalized)", isActive: vm.sort != "popular") { + showFilters = true + } + FilterChip(label: "Genre: \(vm.genre == "all" ? "All" : vm.genre.capitalized)", isActive: vm.genre != "all") { + showFilters = true + } + FilterChip(label: "Status: \(vm.status == "all" ? "All" : vm.status.capitalized)", isActive: vm.status != "all") { + showFilters = true + } + } + .padding(.horizontal) + } + .padding(.bottom, 4) + + Divider() + + // Results + if vm.isLoading && vm.novels.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if vm.novels.isEmpty && !vm.isLoading { + EmptyStateView(icon: "magnifyingglass", title: "No results", message: "Try a different search or filter.").frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 16) { + ForEach(vm.novels) { novel in + NavigationLink(value: NavDestination.book(novel.slug)) { + BrowseCard(novel: novel) + } + .buttonStyle(.plain) + } + + // Infinite scroll trigger + if vm.hasNext { + ProgressView() + .frame(maxWidth: .infinity) + .padding() + .onAppear { Task { await vm.loadNextPage() } } + } + } + .padding() + } + } + } + .navigationTitle("Discover") + .navigationDestination(for: NavDestination.self) { dest in + switch dest { + case .book(let slug): BookDetailView(slug: slug) + case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n) + } + } + .sheet(isPresented: $showFilters) { + BrowseFiltersView(vm: vm) + } + .task { await vm.loadFirstPage() } + .onChange(of: vm.sort) { _, _ in Task { await vm.loadFirstPage() } } + .onChange(of: vm.genre) { _, _ in Task { await vm.loadFirstPage() } } + .onChange(of: vm.status) { _, _ in Task { await vm.loadFirstPage() } } + } + } +} + +// MARK: - Filter chip + +private struct FilterChip: View { + let label: String + let isActive: Bool + let action: () -> Void + var body: some View { + Button(action: action) { + Text(label) + .font(.caption.bold()) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(isActive ? Color.amber.opacity(0.15) : Color(.systemGray6), in: Capsule()) + .foregroundStyle(isActive ? .amber : .primary) + .overlay(Capsule().strokeBorder(isActive ? Color.amber : .clear, lineWidth: 1)) + } + } +} + +// MARK: - Browse card + +private struct BrowseCard: View { + let novel: BrowseNovel + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ZStack(alignment: .topLeading) { + AsyncCoverImage(url: novel.cover) + .frame(height: 200) + .clipShape(RoundedRectangle(cornerRadius: 10)) + if !novel.rank.isEmpty { + Text(novel.rank) + .font(.caption2.bold()) + .padding(.horizontal, 6).padding(.vertical, 3) + .background(.ultraThinMaterial, in: Capsule()) + .padding(6) + } + } + Text(novel.title) + .font(.caption.bold()).lineLimit(2) + if !novel.chapters.isEmpty { + Text(novel.chapters).font(.caption2).foregroundStyle(.secondary) + } + } + } +} + +// MARK: - Filters sheet + +struct BrowseFiltersView: View { + @ObservedObject var vm: BrowseViewModel + @Environment(\.dismiss) private var dismiss + + let sortOptions = ["popular", "new", "updated", "rating", "rank"] + let genreOptions = ["all", "action", "fantasy", "romance", "sci-fi", "mystery", + "horror", "comedy", "drama", "adventure", "martial arts", + "cultivation", "magic", "supernatural", "historical", "slice of life"] + let statusOptions = ["all", "ongoing", "completed"] + + var body: some View { + NavigationStack { + Form { + Section("Sort") { + ForEach(sortOptions, id: \.self) { opt in + HStack { + Text(opt.capitalized) + Spacer() + if vm.sort == opt { Image(systemName: "checkmark").foregroundStyle(.amber) } + } + .contentShape(Rectangle()) + .onTapGesture { vm.sort = opt; dismiss() } + } + } + Section("Genre") { + ForEach(genreOptions, id: \.self) { opt in + HStack { + Text(opt == "all" ? "All Genres" : opt.capitalized) + Spacer() + if vm.genre == opt { Image(systemName: "checkmark").foregroundStyle(.amber) } + } + .contentShape(Rectangle()) + .onTapGesture { vm.genre = opt; dismiss() } + } + } + Section("Status") { + ForEach(statusOptions, id: \.self) { opt in + HStack { + Text(opt.capitalized) + Spacer() + if vm.status == opt { Image(systemName: "checkmark").foregroundStyle(.amber) } + } + .contentShape(Rectangle()) + .onTapGesture { vm.status = opt; dismiss() } + } + } + } + .navigationTitle("Filters") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + .presentationDetents([.medium, .large]) + } +} diff --git a/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift b/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift new file mode 100644 index 0000000..f111b9e --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift @@ -0,0 +1,157 @@ +import SwiftUI +import WebKit + +// MARK: - Chapter Reader + +struct ChapterReaderView: View { + let slug: String + let chapterNumber: Int + + @StateObject private var vm: ChapterReaderViewModel + @EnvironmentObject var audioPlayer: AudioPlayerService + @EnvironmentObject var authStore: AuthStore + + init(slug: String, chapterNumber: Int) { + self.slug = slug + self.chapterNumber = chapterNumber + _vm = StateObject(wrappedValue: ChapterReaderViewModel(slug: slug, chapter: chapterNumber)) + } + + var body: some View { + Group { + if vm.isLoading { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let content = vm.content { + readerContent(content) + } + } + .navigationTitle(vm.content.map { "Ch.\($0.chapter.number)" } ?? "") + .navigationBarTitleDisplayMode(.inline) + .toolbar { audioToolbarButton } + .task { await vm.load() } + .onReceive(NotificationCenter.default.publisher(for: .audioDidFinishChapter)) { note in + if let next = note.userInfo?["next"] as? Int { + vm.navigateTo = next + } + } + .navigationDestination(isPresented: Binding( + get: { vm.navigateTo != nil }, + set: { if !$0 { vm.navigateTo = nil } } + )) { + if let next = vm.navigateTo { + ChapterReaderView(slug: slug, chapterNumber: next) + } + } + .alert("Error", isPresented: .constant(vm.error != nil)) { + Button("OK") { vm.error = nil } + } message: { Text(vm.error ?? "") } + } + + // MARK: - Content + + @ViewBuilder + private func readerContent(_ content: ChapterResponse) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Header + VStack(alignment: .leading, spacing: 4) { + Text(content.chapter.title) + .font(.title2.bold()) + if !content.chapter.dateLabel.isEmpty { + Text(content.chapter.dateLabel) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal) + + Divider() + + // Chapter body + HTMLContentView(html: content.html) + .padding(.horizontal) + + Divider() + + // Prev / Next navigation + HStack { + if let prev = content.prev { + NavigationLink(value: NavDestination.chapter(slug, prev)) { + Label("Ch.\(prev)", systemImage: "chevron.left") + } + .buttonStyle(.bordered) + } + Spacer() + if let next = content.next { + NavigationLink(value: NavDestination.chapter(slug, next)) { + Label("Ch.\(next)", systemImage: "chevron.right") + .labelStyle(ReverseLabelStyle()) + } + .buttonStyle(.borderedProminent) + .tint(.amber) + } + } + .padding() + } + .padding(.vertical) + } + } + + // MARK: - Audio toolbar button + + @ToolbarContentBuilder + private var audioToolbarButton: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + Button { + vm.toggleAudio(audioPlayer: audioPlayer, settings: authStore.settings) + } label: { + Image(systemName: audioPlayer.isActive && + audioPlayer.slug == slug && + audioPlayer.chapter == chapterNumber + ? "speaker.wave.2.fill" : "speaker.wave.2") + .foregroundStyle(.amber) + } + } + } +} + +// MARK: - HTML content renderer using WKWebView + +struct HTMLContentView: UIViewRepresentable { + let html: String + + func makeUIView(context: Context) -> WKWebView { + let wv = WKWebView() + wv.scrollView.isScrollEnabled = false + wv.isOpaque = false + wv.backgroundColor = .clear + return wv + } + + func updateUIView(_ uiView: WKWebView, context: Context) { + let css = """ + body { + font-family: -apple-system, Georgia, serif; + font-size: 17px; + line-height: 1.7; + color: \(UITraitCollection.current.userInterfaceStyle == .dark ? "#e5e5e5" : "#1a1a1a"); + background: transparent; + margin: 0; padding: 0; + } + p { margin: 0 0 1em 0; } + """ + let wrapped = "\(html)" + uiView.loadHTMLString(wrapped, baseURL: nil) + } +} + +// MARK: - Reverse label style (icon on right) + +struct ReverseLabelStyle: LabelStyle { + func makeBody(configuration: Configuration) -> some View { + HStack { + configuration.title + configuration.icon + } + } +} diff --git a/ios/LibNovel/LibNovel/Views/Common/CommonViews.swift b/ios/LibNovel/LibNovel/Views/Common/CommonViews.swift new file mode 100644 index 0000000..de9d521 --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Common/CommonViews.swift @@ -0,0 +1,74 @@ +import SwiftUI +import Kingfisher + +// MARK: - Empty state placeholder used across all screens + +struct EmptyStateView: View { + let icon: String + let title: String + let message: String + + var body: some View { + VStack(spacing: 14) { + Image(systemName: icon) + .font(.system(size: 48)) + .foregroundStyle(.tertiary) + Text(title) + .font(.headline) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + } +} + +// MARK: - Cover image card reused across screens + +struct BookCard: View { + let book: Book + var body: some View { + VStack(alignment: .leading, spacing: 6) { + AsyncCoverImage(url: book.cover) + .frame(height: 200) + .clipShape(RoundedRectangle(cornerRadius: 10)) + Text(book.title) + .font(.caption.bold()) + .lineLimit(2) + Text(book.author) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } +} + +// MARK: - Async cover image with disk/memory caching via Kingfisher + +struct AsyncCoverImage: View { + let url: String + var body: some View { + KFImage(URL(string: url)) + .resizable() + .placeholder { + RoundedRectangle(cornerRadius: 10) + .fill(Color(.systemGray5)) + .overlay(Image(systemName: "book.closed").foregroundStyle(.secondary)) + } + .scaledToFill() + } +} + +// MARK: - Tag chip + +struct TagChip: View { + let label: String + var body: some View { + Text(label) + .font(.caption2.bold()) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color(.systemGray5), in: Capsule()) + } +} diff --git a/ios/LibNovel/LibNovel/Views/Home/HomeView.swift b/ios/LibNovel/LibNovel/Views/Home/HomeView.swift new file mode 100644 index 0000000..c924bce --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Home/HomeView.swift @@ -0,0 +1,148 @@ +import SwiftUI +import Kingfisher + +struct HomeView: View { + @StateObject private var vm = HomeViewModel() + @EnvironmentObject var authStore: AuthStore + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 28) { + + // Stats bar + if let stats = vm.stats { + HStack(spacing: 0) { + StatCell(value: "\(stats.totalBooks)", label: "Books") + Divider().frame(height: 32) + StatCell(value: "\(stats.totalChapters)", label: "Chapters") + Divider().frame(height: 32) + StatCell(value: "\(stats.booksInProgress)", label: "In Progress") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14)) + .padding(.horizontal) + } + + // Continue reading + if !vm.continueReading.isEmpty { + SectionHeader(title: "Continue Reading") + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(vm.continueReading) { item in + NavigationLink(value: NavDestination.book(item.book.slug)) { + ContinueReadingCard(item: item) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + } + } + + // Recently updated + if !vm.recentlyUpdated.isEmpty { + SectionHeader(title: "Recently Updated") + LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 16) { + ForEach(vm.recentlyUpdated) { book in + NavigationLink(value: NavDestination.book(book.slug)) { + BookCard(book: book) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + } + + // Empty state + if vm.continueReading.isEmpty && vm.recentlyUpdated.isEmpty && !vm.isLoading { + EmptyStateView( + icon: "books.vertical", + title: "Your library is empty", + message: "Head to Discover to find novels to read." + ) + .frame(maxWidth: .infinity) + .padding(.top, 60) + } + + if vm.isLoading { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.top, 60) + } + } + .padding(.vertical) + } + .navigationTitle("Home") + .navigationDestination(for: NavDestination.self) { dest in + switch dest { + case .book(let slug): BookDetailView(slug: slug) + case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n) + } + } + .refreshable { await vm.load() } + .task { await vm.load() } + .alert("Error", isPresented: .constant(vm.error != nil)) { + Button("OK") { vm.error = nil } + } message: { + Text(vm.error ?? "") + } + } + } +} + +// MARK: - Supporting components + +private struct StatCell: View { + let value: String + let label: String + var body: some View { + VStack(spacing: 2) { + Text(value).font(.title2.bold()).foregroundStyle(.primary) + Text(label).font(.caption).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } +} + +private struct SectionHeader: View { + let title: String + var body: some View { + Text(title) + .font(.title3.bold()) + .padding(.horizontal) + } +} + +private struct ContinueReadingCard: View { + let item: ContinueReadingItem + var body: some View { + VStack(alignment: .leading, spacing: 6) { + KFImage(URL(string: item.book.cover)) + .resizable() + .placeholder { coverPlaceholder } + .scaledToFill() + .frame(width: 120, height: 170) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay(alignment: .bottomTrailing) { + Text("Ch.\(item.chapter)") + .font(.caption2.bold()) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(.ultraThinMaterial, in: Capsule()) + .padding(6) + } + Text(item.book.title) + .font(.caption.bold()) + .lineLimit(2) + .frame(width: 120, alignment: .leading) + } + } + private var coverPlaceholder: some View { + RoundedRectangle(cornerRadius: 10) + .fill(Color(.systemGray5)) + .frame(width: 120, height: 170) + .overlay(Image(systemName: "book.closed").foregroundStyle(.secondary)) + } +} diff --git a/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift b/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift new file mode 100644 index 0000000..b82f6da --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift @@ -0,0 +1,86 @@ +import SwiftUI +import Kingfisher + +struct LibraryView: View { + @StateObject private var vm = LibraryViewModel() + + var body: some View { + NavigationStack { + Group { + if vm.isLoading && vm.items.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if vm.items.isEmpty { + EmptyStateView( + icon: "bookmark", + title: "No saved books", + message: "Books you save or start reading will appear here." + ) + } else { + ScrollView { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], + spacing: 16 + ) { + ForEach(vm.items) { item in + NavigationLink(value: NavDestination.book(item.book.slug)) { + LibraryCard(item: item) + } + .buttonStyle(.plain) + } + } + .padding() + } + } + } + .navigationTitle("Library") + .navigationDestination(for: NavDestination.self) { dest in + switch dest { + case .book(let slug): BookDetailView(slug: slug) + case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n) + } + } + .refreshable { await vm.load() } + .task { await vm.load() } + .alert("Error", isPresented: .constant(vm.error != nil)) { + Button("OK") { vm.error = nil } + } message: { Text(vm.error ?? "") } + } + } +} + +private struct LibraryCard: View { + let item: LibraryItem + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ZStack(alignment: .bottomTrailing) { + KFImage(URL(string: item.book.cover)) + .resizable() + .placeholder { + RoundedRectangle(cornerRadius: 10) + .fill(Color(.systemGray5)) + .overlay(Image(systemName: "book.closed").foregroundStyle(.secondary)) + } + .scaledToFill() + .frame(height: 200) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + if let ch = item.lastChapter { + Text("Ch.\(ch)") + .font(.caption2.bold()) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(.ultraThinMaterial, in: Capsule()) + .padding(6) + } + } + Text(item.book.title) + .font(.caption.bold()) + .lineLimit(2) + Text(item.book.author) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } +} diff --git a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift new file mode 100644 index 0000000..0fbf8a8 --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift @@ -0,0 +1,192 @@ +import SwiftUI + +// MARK: - Mini player bar (pinned above tab bar) + +struct MiniPlayerView: View { + @Binding var showFullPlayer: Bool + @EnvironmentObject var audioPlayer: AudioPlayerService + + var body: some View { + VStack(spacing: 0) { + // Seek bar (thin line at top of bar) + GeometryReader { geo in + ZStack(alignment: .leading) { + Rectangle().fill(Color(.systemGray4)).frame(height: 2) + Rectangle() + .fill(Color.amber) + .frame(width: geo.size.width * progress, height: 2) + } + } + .frame(height: 2) + + HStack(spacing: 12) { + // Cover thumbnail → tap to open full player + Button { showFullPlayer = true } label: { + AsyncCoverImage(url: audioPlayer.coverURL) + .frame(width: 40, height: 40) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + + // Track info + VStack(alignment: .leading, spacing: 2) { + Text(audioPlayer.bookTitle) + .font(.caption.bold()) + .lineLimit(1) + Text(chapterLabel) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .onTapGesture { showFullPlayer = true } + + // Status spinner or playback controls + switch audioPlayer.status { + case .loading, .generating: + ProgressView() + .scaleEffect(0.8) + .frame(width: 36) + case .ready: + Button { audioPlayer.togglePlayPause() } label: { + Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") + .font(.title2) + } + .buttonStyle(.plain) + case .error: + Image(systemName: "exclamationmark.circle").foregroundStyle(.red) + default: + EmptyView() + } + + // Dismiss + Button { audioPlayer.stop() } label: { + Image(systemName: "xmark").font(.caption.bold()) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 0)) + .shadow(color: .black.opacity(0.12), radius: 4, y: -2) + } + + private var progress: CGFloat { + guard audioPlayer.duration > 0 else { return 0 } + return CGFloat(audioPlayer.currentTime / audioPlayer.duration) + } + + private var chapterLabel: String { + audioPlayer.chapterTitle.isEmpty + ? "Chapter \(audioPlayer.chapter)" + : audioPlayer.chapterTitle + } +} + +// MARK: - Full player sheet + +struct FullPlayerView: View { + @EnvironmentObject var audioPlayer: AudioPlayerService + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + VStack(spacing: 28) { + // Cover art + AsyncCoverImage(url: audioPlayer.coverURL) + .frame(width: 200, height: 290) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .shadow(radius: 10) + + // Titles + VStack(spacing: 4) { + Text(audioPlayer.chapterTitle.isEmpty ? "Chapter \(audioPlayer.chapter)" : audioPlayer.chapterTitle) + .font(.headline) + .multilineTextAlignment(.center) + Text(audioPlayer.bookTitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.horizontal) + + // Seek bar + VStack(spacing: 4) { + Slider( + value: Binding( + get: { audioPlayer.currentTime }, + set: { audioPlayer.seek(to: $0) } + ), + in: 0...max(audioPlayer.duration, 1) + ) + .tint(.amber) + + HStack { + Text(formatTime(audioPlayer.currentTime)) + Spacer() + Text(formatTime(audioPlayer.duration)) + } + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.horizontal) + + // Controls row + HStack(spacing: 40) { + Button { audioPlayer.skip(by: -15) } label: { + Image(systemName: "gobackward.15").font(.title) + } + Button { audioPlayer.togglePlayPause() } label: { + Image(systemName: audioPlayer.isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 64)) + .foregroundStyle(.amber) + } + Button { audioPlayer.skip(by: 30) } label: { + Image(systemName: "goforward.30").font(.title) + } + } + .buttonStyle(.plain) + + // Speed + auto-next row + HStack(spacing: 20) { + Menu { + ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 2.0], id: \.self) { s in + Button("\(s, specifier: "%.2g")×") { audioPlayer.setSpeed(s) } + } + } label: { + Text("\(audioPlayer.speed, specifier: "%.2g")×") + .font(.subheadline.bold()) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color(.systemGray5), in: Capsule()) + } + + Toggle(isOn: $audioPlayer.autoNext) { + Label("Auto-next", systemImage: "forward.end") + .font(.subheadline) + } + .toggleStyle(.button) + .tint(.amber) + } + + Spacer() + } + .padding(.top, 24) + .navigationTitle("Now Playing") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + } + + private func formatTime(_ seconds: Double) -> String { + guard seconds.isFinite, seconds >= 0 else { return "0:00" } + let s = Int(seconds) + return "\(s / 60):\(String(format: "%02d", s % 60))" + } +} diff --git a/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift b/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift new file mode 100644 index 0000000..c92e8f9 --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift @@ -0,0 +1,218 @@ +import SwiftUI + +struct ProfileView: View { + @EnvironmentObject var authStore: AuthStore + @StateObject private var vm = ProfileViewModel() + @State private var showChangePassword = false + + var body: some View { + NavigationStack { + List { + // User header + Section { + HStack(spacing: 14) { + Image(systemName: "person.circle.fill") + .font(.system(size: 48)) + .foregroundStyle(.amber) + VStack(alignment: .leading, spacing: 2) { + Text(authStore.user?.username ?? "") + .font(.headline) + Text(authStore.user?.role.capitalized ?? "") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 6) + } + + // Reading settings + Section("Reading Settings") { + voicePicker + speedSlider + Toggle("Auto-advance chapter", isOn: Binding( + get: { authStore.settings.autoNext }, + set: { newVal in + Task { + var s = authStore.settings + s.autoNext = newVal + await authStore.saveSettings(s) + } + } + )) + .tint(.amber) + } + + // Sessions + Section("Active Sessions") { + if vm.sessionsLoading { + ProgressView() + } else { + ForEach(vm.sessions) { session in + SessionRow(session: session) { + Task { await vm.revokeSession(id: session.id) } + } + } + } + } + + // Account + Section("Account") { + Button("Change Password") { showChangePassword = true } + Button("Sign Out", role: .destructive) { + Task { await authStore.logout() } + } + } + } + .navigationTitle("Profile") + .task { await vm.loadSessions() } + .sheet(isPresented: $showChangePassword) { + ChangePasswordView() + } + .alert("Error", isPresented: .constant(vm.error != nil)) { + Button("OK") { vm.error = nil } + } message: { Text(vm.error ?? "") } + } + } + + // MARK: - Voice picker + + @ViewBuilder + private var voicePicker: some View { + Picker("TTS Voice", selection: Binding( + get: { authStore.settings.voice }, + set: { newVoice in + Task { + var s = authStore.settings + s.voice = newVoice + await authStore.saveSettings(s) + } + } + )) { + if vm.voices.isEmpty { + Text("Default").tag("af_bella") + } else { + ForEach(vm.voices, id: \.self) { v in + Text(v).tag(v) + } + } + } + .task { await vm.loadVoices() } + } + + // MARK: - Speed slider + + @ViewBuilder + private var speedSlider: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Playback Speed") + Spacer() + Text("\(authStore.settings.speed, specifier: "%.1f")×") + .foregroundStyle(.secondary) + } + Slider( + value: Binding( + get: { authStore.settings.speed }, + set: { newSpeed in + Task { + var s = authStore.settings + s.speed = newSpeed + await authStore.saveSettings(s) + } + } + ), + in: 0.5...3.0, step: 0.25 + ) + .tint(.amber) + } + } +} + +// MARK: - Session row + +private struct SessionRow: View { + let session: UserSession + let onRevoke: () -> Void + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: "iphone") + Text(session.userAgent.isEmpty ? "Unknown device" : session.userAgent) + .font(.subheadline) + .lineLimit(1) + Spacer() + if session.isCurrent { + Text("This device") + .font(.caption2.bold()) + .foregroundStyle(.amber) + } else { + Button("Revoke", role: .destructive, action: onRevoke) + .font(.caption) + } + } + Text("Last seen: \(session.lastSeen.prefix(10))") + .font(.caption2) + .foregroundStyle(.secondary) + } + } +} + +// MARK: - Change password sheet + +struct ChangePasswordView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var authStore: AuthStore + @State private var current = "" + @State private var newPwd = "" + @State private var confirm = "" + @State private var isLoading = false + @State private var error: String? + @State private var success = false + + var body: some View { + NavigationStack { + Form { + Section { + SecureField("Current password", text: $current) + SecureField("New password", text: $newPwd) + SecureField("Confirm new password", text: $confirm) + } + if let error { + Text(error).foregroundStyle(.red).font(.caption) + } + if success { + Text("Password changed successfully").foregroundStyle(.green).font(.caption) + } + } + .navigationTitle("Change Password") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .topBarTrailing) { + Button("Save") { save() } + .disabled(isLoading || newPwd.count < 4 || newPwd != confirm) + } + } + } + } + + private func save() { + guard newPwd == confirm else { error = "Passwords do not match"; return } + isLoading = true + error = nil + Task { + do { + struct Body: Encodable { let currentPassword, newPassword: String } + let _: EmptyResponse = try await APIClient.shared.fetch( + "/api/auth/change-password", method: "POST", + body: Body(currentPassword: current, newPassword: newPwd) + ) + success = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { dismiss() } + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + } +} diff --git a/ios/LibNovel/LibNovelTests/LibNovelTests.swift b/ios/LibNovel/LibNovelTests/LibNovelTests.swift new file mode 100644 index 0000000..c076f88 --- /dev/null +++ b/ios/LibNovel/LibNovelTests/LibNovelTests.swift @@ -0,0 +1,9 @@ +import XCTest +@testable import LibNovel + +final class LibNovelTests: XCTestCase { + func testExample() throws { + // Placeholder — add real tests here + XCTAssert(true) + } +} diff --git a/ios/LibNovel/project.yml b/ios/LibNovel/project.yml new file mode 100644 index 0000000..93af1ba --- /dev/null +++ b/ios/LibNovel/project.yml @@ -0,0 +1,90 @@ +name: LibNovel +options: + bundleIdPrefix: cc.kalekber + deploymentTarget: + iOS: "17.0" + xcodeVersion: "16.0" + generateEmptyDirectories: true + indentWidth: 4 + tabWidth: 4 + usesTabs: false + +settings: + base: + SWIFT_VERSION: "5.10" + ENABLE_PREVIEWS: YES + MARKETING_VERSION: "1.0.0" + CURRENT_PROJECT_VERSION: "1" + LIBNOVEL_BASE_URL: "https://v2.libnovel.kalekber.cc" + configs: + Debug: + SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG + Release: + SWIFT_ACTIVE_COMPILATION_CONDITIONS: "" + +packages: + # Markdown rendering — renders chapter HTML from the server + MarkdownUI: + url: https://github.com/gonzalezreal/swift-markdown-ui + from: "2.4.0" + # Async image loading with caching + Kingfisher: + url: https://github.com/onevcat/Kingfisher + from: "8.0.0" + +targets: + LibNovel: + type: application + platform: iOS + deploymentTarget: "17.0" + sources: + - path: LibNovel + excludes: + - "**/.DS_Store" + - "Resources/Info.plist" + resources: + - path: LibNovel/Resources/Assets.xcassets + dependencies: + - package: Kingfisher + - package: MarkdownUI + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: cc.kalekber.libnovel + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + TARGETED_DEVICE_FAMILY: "1,2" # iPhone + iPad + GENERATE_INFOPLIST_FILE: NO + INFOPLIST_FILE: LibNovel/Resources/Info.plist + + LibNovelTests: + type: bundle.unit-test + platform: iOS + deploymentTarget: "17.0" + sources: + - path: LibNovelTests + dependencies: + - target: LibNovel + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: cc.kalekber.libnovel.tests + +schemes: + LibNovel: + build: + targets: + LibNovel: all + run: + config: Debug + environmentVariables: + LIBNOVEL_BASE_URL: + value: "https://v2.libnovel.kalekber.cc" + isEnabled: true + test: + config: Debug + targets: + - LibNovelTests + profile: + config: Release + analyze: + config: Debug + archive: + config: Release diff --git a/justfile b/justfile index 7747271..43f5926 100644 --- a/justfile +++ b/justfile @@ -3,6 +3,7 @@ scraper_dir := "scraper" ui_dir := "ui" +ios_dir := "ios/LibNovel" # ─── Build ──────────────────────────────────────────────────────────────────── @@ -78,6 +79,30 @@ ui-install: ui-build: cd {{ui_dir}} && npm run build +# ─── iOS ────────────────────────────────────────────────────────────────────── + +# Regenerate LibNovel.xcodeproj from project.yml (run after structural changes) +ios-gen: + cd {{ios_dir}} && xcodegen generate --spec project.yml --project . + +# Build the iOS app for the simulator (no signing required) +ios-build: ios-gen + cd {{ios_dir}} && xcodebuild \ + -project LibNovel.xcodeproj \ + -scheme LibNovel \ + -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + CODE_SIGNING_ALLOWED=NO + +# Run unit tests on the simulator +ios-test: ios-gen + cd {{ios_dir}} && xcodebuild test \ + -project LibNovel.xcodeproj \ + -scheme LibNovel \ + -configuration Debug \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + CODE_SIGNING_ALLOWED=NO + # ─── Docker Compose ─────────────────────────────────────────────────────────── # Start all services (browserless, kokoro, scraper, minio, pocketbase) diff --git a/ui/src/routes/api/auth/change-password/+server.ts b/ui/src/routes/api/auth/change-password/+server.ts new file mode 100644 index 0000000..dba2d4e --- /dev/null +++ b/ui/src/routes/api/auth/change-password/+server.ts @@ -0,0 +1,47 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { changePassword } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * POST /api/auth/change-password + * Body: { currentPassword: string, newPassword: string } + * Requires authentication. + */ +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.user) { + error(401, 'Not authenticated'); + } + + let body: { currentPassword?: string; newPassword?: string }; + try { + body = await request.json(); + } catch { + error(400, 'Invalid JSON body'); + } + + const currentPassword = body.currentPassword ?? ''; + const newPassword = body.newPassword ?? ''; + + if (!currentPassword || !newPassword) { + error(400, 'currentPassword and newPassword are required'); + } + + if (newPassword.length < 4) { + error(400, 'New password must be at least 4 characters'); + } + + try { + const ok = await changePassword(locals.user.id, currentPassword, newPassword); + if (!ok) { + error(401, 'Current password is incorrect'); + } + } catch (e: unknown) { + // Re-throw SvelteKit errors as-is + if (e && typeof e === 'object' && 'status' in e) throw e; + log.error('api/auth/change-password', 'unexpected error', { err: String(e) }); + error(500, 'An error occurred. Please try again.'); + } + + return json({ ok: true }); +}; diff --git a/ui/src/routes/api/auth/login/+server.ts b/ui/src/routes/api/auth/login/+server.ts new file mode 100644 index 0000000..5d04a36 --- /dev/null +++ b/ui/src/routes/api/auth/login/+server.ts @@ -0,0 +1,75 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { loginUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase'; +import { createAuthToken } from '../../../../hooks.server'; +import { log } from '$lib/server/logger'; +import { randomBytes } from 'node:crypto'; + +const AUTH_COOKIE = 'libnovel_auth'; +const ONE_YEAR = 60 * 60 * 24 * 365; + +/** + * POST /api/auth/login + * Body: { username: string, password: string } + * Returns: { token: string, user: { id, username, role } } + * + * Sets the libnovel_auth cookie and returns the raw token value so the + * iOS app can persist it for subsequent requests. + */ +export const POST: RequestHandler = async ({ request, cookies, locals }) => { + let body: { username?: string; password?: string }; + try { + body = await request.json(); + } catch { + error(400, 'Invalid JSON body'); + } + + const username = (body.username ?? '').trim(); + const password = body.password ?? ''; + + if (!username || !password) { + error(400, 'Username and password are required'); + } + + let user; + try { + user = await loginUser(username, password); + } catch (e) { + log.error('api/auth/login', 'unexpected error', { username, err: String(e) }); + error(500, 'An error occurred. Please try again.'); + } + + if (!user) { + error(401, 'Invalid username or password'); + } + + // Merge anonymous session progress (non-fatal) + mergeSessionProgress(locals.sessionId, user.id).catch((e) => + log.warn('api/auth/login', 'mergeSessionProgress failed (non-fatal)', { err: String(e) }) + ); + + const authSessionId = randomBytes(16).toString('hex'); + + const userAgent = request.headers.get('user-agent') ?? ''; + const ip = + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + request.headers.get('x-real-ip') ?? + ''; + createUserSession(user.id, authSessionId, userAgent, ip).catch((e) => + log.warn('api/auth/login', 'createUserSession failed (non-fatal)', { err: String(e) }) + ); + + const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId); + + cookies.set(AUTH_COOKIE, token, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: ONE_YEAR + }); + + return json({ + token, + user: { id: user.id, username: user.username, role: user.role ?? 'user' } + }); +}; diff --git a/ui/src/routes/api/auth/logout/+server.ts b/ui/src/routes/api/auth/logout/+server.ts new file mode 100644 index 0000000..9321e34 --- /dev/null +++ b/ui/src/routes/api/auth/logout/+server.ts @@ -0,0 +1,15 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +const AUTH_COOKIE = 'libnovel_auth'; + +/** + * POST /api/auth/logout + * Clears the auth cookie and returns { ok: true }. + * Does not revoke the session record from PocketBase — + * for full revocation use DELETE /api/sessions/[id] first. + */ +export const POST: RequestHandler = async ({ cookies }) => { + cookies.delete(AUTH_COOKIE, { path: '/' }); + return json({ ok: true }); +}; diff --git a/ui/src/routes/api/auth/me/+server.ts b/ui/src/routes/api/auth/me/+server.ts new file mode 100644 index 0000000..0535d7a --- /dev/null +++ b/ui/src/routes/api/auth/me/+server.ts @@ -0,0 +1,19 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +/** + * GET /api/auth/me + * Returns the currently authenticated user from the request's auth cookie. + * Returns 401 if not authenticated. + */ +export const GET: RequestHandler = async ({ locals }) => { + if (!locals.user) { + error(401, 'Not authenticated'); + } + return json({ + id: locals.user.id, + username: locals.user.username, + role: locals.user.role, + created: locals.user.created ?? '' + }); +}; diff --git a/ui/src/routes/api/auth/register/+server.ts b/ui/src/routes/api/auth/register/+server.ts new file mode 100644 index 0000000..58d0be7 --- /dev/null +++ b/ui/src/routes/api/auth/register/+server.ts @@ -0,0 +1,84 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { createUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase'; +import { createAuthToken } from '../../../../hooks.server'; +import { log } from '$lib/server/logger'; +import { randomBytes } from 'node:crypto'; + +const AUTH_COOKIE = 'libnovel_auth'; +const ONE_YEAR = 60 * 60 * 24 * 365; + +/** + * POST /api/auth/register + * Body: { username: string, password: string } + * Returns: { token: string, user: { id, username, role } } + * + * Sets the libnovel_auth cookie and returns the raw token value so the + * iOS app can persist it for subsequent requests. + */ +export const POST: RequestHandler = async ({ request, cookies, locals }) => { + let body: { username?: string; password?: string }; + try { + body = await request.json(); + } catch { + error(400, 'Invalid JSON body'); + } + + const username = (body.username ?? '').trim(); + const password = body.password ?? ''; + + if (!username || !password) { + error(400, 'Username and password are required'); + } + if (username.length < 3 || username.length > 32) { + error(400, 'Username must be between 3 and 32 characters'); + } + if (!/^[a-zA-Z0-9_-]+$/.test(username)) { + error(400, 'Username may only contain letters, numbers, underscores and hyphens'); + } + if (password.length < 8) { + error(400, 'Password must be at least 8 characters'); + } + + let user; + try { + user = await createUser(username, password); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Registration failed.'; + if (msg.includes('Username already taken')) { + error(409, 'That username is already taken'); + } + log.error('api/auth/register', 'unexpected error', { username, err: String(e) }); + error(500, 'An error occurred. Please try again.'); + } + + // Merge anonymous session progress (non-fatal) + mergeSessionProgress(locals.sessionId, user.id).catch((e) => + log.warn('api/auth/register', 'mergeSessionProgress failed (non-fatal)', { err: String(e) }) + ); + + const authSessionId = randomBytes(16).toString('hex'); + + const userAgent = request.headers.get('user-agent') ?? ''; + const ip = + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + request.headers.get('x-real-ip') ?? + ''; + createUserSession(user.id, authSessionId, userAgent, ip).catch((e) => + log.warn('api/auth/register', 'createUserSession failed (non-fatal)', { err: String(e) }) + ); + + const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId); + + cookies.set(AUTH_COOKIE, token, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: ONE_YEAR + }); + + return json({ + token, + user: { id: user.id, username: user.username, role: user.role ?? 'user' } + }); +}; diff --git a/ui/src/routes/api/book/[slug]/+server.ts b/ui/src/routes/api/book/[slug]/+server.ts new file mode 100644 index 0000000..4ee1aa0 --- /dev/null +++ b/ui/src/routes/api/book/[slug]/+server.ts @@ -0,0 +1,105 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getBook, listChapterIdx, getProgress, isBookSaved } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; +import { env } from '$env/dynamic/private'; + +const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; + +interface PreviewChapter { + number: number; + title: string; + url: string; +} + +/** + * GET /api/book/[slug] + * Returns book metadata, chapter list, progress, and library status. + * Falls back to a live scraper preview if the book is not in PocketBase. + * + * Response shape mirrors BookDetailResponse in the iOS APIClient. + */ +export const GET: RequestHandler = async ({ params, locals }) => { + const { slug } = params; + + // Try PocketBase first + let book = await getBook(slug).catch((e) => { + log.error('api/book', 'getBook failed', { slug, err: String(e) }); + return null; + }); + + if (book) { + let chapters, progress, saved; + try { + [chapters, progress, saved] = await Promise.all([ + listChapterIdx(slug), + getProgress(locals.sessionId, slug, locals.user?.id), + isBookSaved(locals.sessionId, slug, locals.user?.id) + ]); + } catch (e) { + log.error('api/book', 'failed to load book detail data', { slug, err: String(e) }); + error(500, 'Failed to load book'); + } + + return json({ + book, + chapters, + preview_chapters: null, + in_lib: true, + saved, + last_chapter: progress?.chapter ?? null + }); + } + + // Fall back to live scraper preview + try { + const res = await fetch(`${SCRAPER_URL}/api/book-preview/${encodeURIComponent(slug)}`); + if (!res.ok) { + log.warn('api/book', 'book-preview returned error', { slug, status: res.status }); + error(404, `Book "${slug}" not found`); + } + const preview: { + in_lib: boolean; + meta: { + slug: string; + title: string; + author: string; + cover: string; + status: string; + genres: string[]; + summary: string; + total_chapters: number; + source_url: string; + }; + chapters: PreviewChapter[]; + } = await res.json(); + + const previewBook = { + id: '', + slug: preview.meta.slug || slug, + title: preview.meta.title, + author: preview.meta.author, + cover: preview.meta.cover, + status: preview.meta.status, + genres: preview.meta.genres ?? [], + summary: preview.meta.summary, + total_chapters: preview.meta.total_chapters, + source_url: preview.meta.source_url, + ranking: 0, + meta_updated: '' + }; + + return json({ + book: previewBook, + chapters: [], + preview_chapters: preview.chapters, + in_lib: preview.in_lib, + saved: false, + last_chapter: null + }); + } catch (e) { + if (e instanceof Error && 'status' in e) throw e; + log.error('api/book', 'book-preview fetch failed', { slug, err: String(e) }); + error(404, `Book "${slug}" not found`); + } +}; diff --git a/ui/src/routes/api/chapter/[slug]/[n]/+server.ts b/ui/src/routes/api/chapter/[slug]/[n]/+server.ts new file mode 100644 index 0000000..c7c74d6 --- /dev/null +++ b/ui/src/routes/api/chapter/[slug]/[n]/+server.ts @@ -0,0 +1,125 @@ +import { json, error } from '@sveltejs/kit'; +import { marked } from 'marked'; +import type { RequestHandler } from './$types'; +import { getBook, listChapterIdx } from '$lib/server/pocketbase'; +import { presignChapter } from '$lib/server/minio'; +import { log } from '$lib/server/logger'; +import { env } from '$env/dynamic/private'; + +const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; + +/** + * GET /api/chapter/[slug]/[n] + * Returns rendered chapter HTML, navigation info, and voice list. + * Supports ?preview=1&chapter_url=...&title=... for un-scraped books. + * + * Response shape mirrors ChapterResponse in the iOS APIClient. + */ +export const GET: RequestHandler = async ({ params, url, locals }) => { + const { slug } = params; + const n = parseInt(params.n, 10); + + if (!n || n < 1) error(400, 'Invalid chapter number'); + + const isPreview = url.searchParams.get('preview') === '1'; + const chapterUrl = url.searchParams.get('chapter_url') ?? ''; + const chapterTitle = url.searchParams.get('title') ?? ''; + + if (isPreview) { + // Preview path: scrape live, nothing from PocketBase/MinIO + const previewParams = new URLSearchParams(); + if (chapterUrl) previewParams.set('chapter_url', chapterUrl); + if (chapterTitle) previewParams.set('title', chapterTitle); + + let chapterData: { slug: string; number: number; title: string; text: string; url: string }; + try { + const res = await fetch( + `${SCRAPER_URL}/api/chapter-text-preview/${encodeURIComponent(slug)}/${n}?${previewParams.toString()}` + ); + if (!res.ok) { + log.error('api/chapter', 'chapter-text-preview returned error', { slug, n, status: res.status }); + error(404, `Chapter ${n} not found`); + } + chapterData = await res.json(); + } catch (e) { + if (e instanceof Error && 'status' in e) throw e; + log.error('api/chapter', 'chapter-text-preview fetch failed', { slug, n, err: String(e) }); + error(502, 'Could not fetch chapter preview'); + } + + const html = chapterData.text + ? '

' + chapterData.text.replace(/\n{2,}/g, '

').replace(/\n/g, '
') + '

' + : ''; + + let voices: string[] = []; + try { + const vRes = await fetch(`${SCRAPER_URL}/api/voices`); + if (vRes.ok) { + const d = (await vRes.json()) as { voices: string[] }; + voices = d.voices ?? []; + } + } catch { + // Non-critical + } + + const pb = await getBook(slug).catch(() => null); + + return json({ + book: { slug, title: pb?.title ?? slug, cover: pb?.cover ?? '' }, + chapter: { id: '', slug, number: n, title: chapterData.title || `Chapter ${n}`, date_label: '' }, + html, + voices, + prev: null, + next: null, + chapters: [], + is_preview: true + }); + } + + // Normal path: PocketBase + MinIO + const [book, chapters, voicesRes] = await Promise.all([ + getBook(slug), + listChapterIdx(slug), + fetch(`${SCRAPER_URL}/api/voices`).catch(() => null) + ]); + + if (!book) error(404, `Book "${slug}" not found`); + + const chapterIdx = chapters.find((c) => c.number === n); + if (!chapterIdx) error(404, `Chapter ${n} not found`); + + let voices: string[] = []; + try { + if (voicesRes?.ok) { + const data = (await voicesRes.json()) as { voices: string[] }; + voices = data.voices ?? []; + } + } catch { + // Non-critical + } + + let html = ''; + try { + const presignUrl = await presignChapter(slug, n); + const res = await fetch(presignUrl); + if (!res.ok) throw new Error(`MinIO returned ${res.status}`); + const markdown = await res.text(); + html = await marked(markdown, { async: true }); + } catch (e) { + log.error('api/chapter', 'failed to fetch chapter content', { slug, n, err: String(e) }); + } + + const prevChapter = chapters.find((c) => c.number === n - 1) ?? null; + const nextChapter = chapters.find((c) => c.number === n + 1) ?? null; + + return json({ + book: { slug: book.slug, title: book.title, cover: book.cover ?? '' }, + chapter: chapterIdx, + html, + voices, + prev: prevChapter ? prevChapter.number : null, + next: nextChapter ? nextChapter.number : null, + chapters: chapters.map((c) => ({ number: c.number, title: c.title })), + is_preview: false + }); +}; diff --git a/ui/src/routes/api/home/+server.ts b/ui/src/routes/api/home/+server.ts new file mode 100644 index 0000000..7d35368 --- /dev/null +++ b/ui/src/routes/api/home/+server.ts @@ -0,0 +1,48 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listBooks, recentlyAddedBooks, allProgress, getHomeStats } 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. + * Requires authentication (enforced by layout guard). + */ +export const GET: RequestHandler = async ({ locals }) => { + let allBooks: Book[] = []; + let recentBooks: Book[] = []; + let progressList: Progress[] = []; + let stats = { totalBooks: 0, totalChapters: 0 }; + + try { + [allBooks, recentBooks, progressList, stats] = await Promise.all([ + listBooks(), + recentlyAddedBooks(8), + allProgress(locals.sessionId, locals.user?.id), + getHomeStats() + ]); + } catch (e) { + log.error('api/home', 'failed to load home data', { err: String(e) }); + } + + const bookMap = new Map(allBooks.map((b) => [b.slug, b])); + + const continueReading = progressList + .filter((p) => bookMap.has(p.slug)) + .slice(0, 6) + .map((p) => ({ book: bookMap.get(p.slug)!, chapter: p.chapter })); + + const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug)); + const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6); + + return json({ + continue_reading: continueReading, + recently_updated: recentlyUpdated, + stats: { + totalBooks: stats.totalBooks, + totalChapters: stats.totalChapters, + booksInProgress: continueReading.length + } + }); +}; diff --git a/ui/src/routes/api/library/+server.ts b/ui/src/routes/api/library/+server.ts new file mode 100644 index 0000000..6f1c1eb --- /dev/null +++ b/ui/src/routes/api/library/+server.ts @@ -0,0 +1,61 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listBooks, allProgress, getSavedSlugs } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * GET /api/library + * Returns the user's library: books they have started reading or explicitly saved. + * Each item includes the book record, the last chapter read, and saved_at timestamp. + * + * Response shape mirrors LibraryItem in the iOS APIClient. + */ +export const GET: RequestHandler = async ({ locals }) => { + let allBooks: Awaited>; + let progressList: Awaited>; + let savedSlugs: Set; + + try { + [allBooks, progressList, savedSlugs] = await Promise.all([ + listBooks(), + allProgress(locals.sessionId, locals.user?.id), + getSavedSlugs(locals.sessionId, locals.user?.id) + ]); + } catch (e) { + log.error('api/library', 'failed to load library data', { err: String(e) }); + allBooks = []; + progressList = []; + savedSlugs = new Set(); + } + + const progressMap: Record = {}; + const progressUpdatedMap: Record = {}; + for (const p of progressList) { + progressMap[p.slug] = p.chapter; + progressUpdatedMap[p.slug] = p.updated; + } + + const progressSlugs = new Set(progressList.map((p) => p.slug)); + const books = allBooks.filter((b) => progressSlugs.has(b.slug) || savedSlugs.has(b.slug)); + + const withProgress = books.filter((b) => progressSlugs.has(b.slug)); + const savedOnly = books + .filter((b) => !progressSlugs.has(b.slug)) + .sort((a, b) => (a.title ?? '').localeCompare(b.title ?? '')); + + withProgress.sort((a, b) => { + const ta = progressUpdatedMap[a.slug] ?? ''; + const tb = progressUpdatedMap[b.slug] ?? ''; + return tb.localeCompare(ta); + }); + + const ordered = [...withProgress, ...savedOnly]; + + const items = ordered.map((book) => ({ + book, + last_chapter: progressMap[book.slug] ?? null, + saved_at: progressUpdatedMap[book.slug] ?? new Date().toISOString() + })); + + return json(items); +}; diff --git a/ui/src/routes/api/presign/audio/+server.ts b/ui/src/routes/api/presign/audio/+server.ts index 04306f8..d7e4cdd 100644 --- a/ui/src/routes/api/presign/audio/+server.ts +++ b/ui/src/routes/api/presign/audio/+server.ts @@ -11,7 +11,8 @@ import { log } from '$lib/server/logger'; */ export const GET: RequestHandler = async ({ url }) => { const slug = url.searchParams.get('slug'); - const n = parseInt(url.searchParams.get('n') ?? '', 10); + // Accept both 'n' (web) and 'chapter' (iOS) as the chapter number param + const n = parseInt(url.searchParams.get('n') ?? url.searchParams.get('chapter') ?? '', 10); const voice = url.searchParams.get('voice') ?? undefined; if (!slug || !n || n < 1) { diff --git a/ui/src/routes/api/progress/[slug]/+server.ts b/ui/src/routes/api/progress/[slug]/+server.ts new file mode 100644 index 0000000..658d409 --- /dev/null +++ b/ui/src/routes/api/progress/[slug]/+server.ts @@ -0,0 +1,34 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { setProgress } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * POST /api/progress/[slug] + * Body: { chapter: number } + * Records the user's reading position for a specific book. + * + * This is a slug-in-path variant of POST /api/progress (which takes slug in body). + * Used by the iOS app where slug is part of the URL path. + */ +export const POST: RequestHandler = async ({ params, request, locals }) => { + const { slug } = params; + const body = await request.json().catch(() => null); + + if (!body || typeof body.chapter !== 'number') { + error(400, 'Invalid body — expected { chapter: number }'); + } + + try { + await setProgress(locals.sessionId, slug, body.chapter, locals.user?.id); + } catch (e) { + log.error('api/progress/[slug]', 'setProgress failed', { + slug, + chapter: body.chapter, + err: String(e) + }); + error(500, 'Failed to save progress'); + } + + return json({ ok: true }); +}; diff --git a/ui/src/routes/api/ranking/+server.ts b/ui/src/routes/api/ranking/+server.ts new file mode 100644 index 0000000..f18d2b1 --- /dev/null +++ b/ui/src/routes/api/ranking/+server.ts @@ -0,0 +1,27 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; +import { log } from '$lib/server/logger'; + +const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; + +/** + * GET /api/ranking + * Proxies to the Go scraper's /api/ranking endpoint. + * Returns the top-ranked novels list as JSON. + */ +export const GET: RequestHandler = async () => { + try { + const res = await fetch(`${SCRAPER_URL}/api/ranking`); + if (!res.ok) { + log.error('api/ranking', 'scraper returned error', { status: res.status }); + error(502, `Ranking fetch failed: ${res.status}`); + } + const data = await res.json(); + return json(data); + } catch (e) { + if (e instanceof Error && 'status' in e) throw e; + log.error('api/ranking', 'network error', { err: String(e) }); + error(502, 'Could not load ranking'); + } +}; diff --git a/ui/src/routes/api/search/+server.ts b/ui/src/routes/api/search/+server.ts new file mode 100644 index 0000000..b802b2a --- /dev/null +++ b/ui/src/routes/api/search/+server.ts @@ -0,0 +1,36 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; +import { log } from '$lib/server/logger'; + +const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; + +/** + * GET /api/search?q= + * Proxies to the Go scraper's /api/search endpoint. + * Returns: { results, local_count, remote_count } + * + * Response shape mirrors SearchResponse in the iOS APIClient. + */ +export const GET: RequestHandler = async ({ url }) => { + const q = url.searchParams.get('q') ?? ''; + + if (q.trim().length < 2) { + return json({ results: [], local_count: 0, remote_count: 0 }); + } + + const apiURL = `${SCRAPER_URL}/api/search?q=${encodeURIComponent(q.trim())}`; + try { + const res = await fetch(apiURL); + if (!res.ok) { + log.error('api/search', 'scraper returned error', { status: res.status, q }); + error(502, `Search failed: ${res.status}`); + } + const data = await res.json(); + return json(data); + } catch (e) { + if (e instanceof Error && 'status' in e) throw e; + log.error('api/search', 'network error', { q, err: String(e) }); + error(502, 'Could not reach search service'); + } +};