Compare commits

...

94 Commits

Author SHA1 Message Date
Admin
52f876d8e8 feat: avatar upload via presigned PUT URL flow
Some checks failed
CI / Scraper / Lint (push) Successful in 11s
CI / Scraper / Lint (pull_request) Successful in 9s
CI / Scraper / Test (push) Successful in 19s
CI / UI / Build (push) Successful in 21s
CI / Scraper / Test (pull_request) Successful in 9s
CI / UI / Build (pull_request) Successful in 27s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 47s
CI / UI / Docker Push (pull_request) Has been skipped
Release / UI / Build (push) Successful in 21s
Release / UI / Docker (push) Successful in 5m1s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
CI / UI / Docker Push (push) Failing after 9m10s
iOS CI / Build (pull_request) Failing after 4m3s
iOS CI / Test (pull_request) Has been skipped
- Go scraper: add PresignAvatarUploadURL/PresignAvatarURL/DeleteAvatar to
  Store interface, implement on HybridStore+MinioClient, register
  GET /api/presign/avatar-upload/{userId} and /api/presign/avatar/{userId}
- SvelteKit: replace direct AWS S3 SDK in minio.ts with presign calls to
  the Go scraper; rewrite avatar +server.ts (POST=presign, PATCH=record key)
- iOS: rewrite uploadAvatar() as 3-step presigned PUT flow; refactor
  chip components into shared ChipButton in CommonViews.swift
2026-03-10 17:05:43 +05:00
Admin
72eed89f59 fix(ios): expose public validateToken() overload for post-avatar-upload refresh
Some checks failed
CI / Scraper / Test (pull_request) Successful in 9s
CI / Scraper / Lint (pull_request) Successful in 12s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 31s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 4m36s
iOS CI / Build (pull_request) Successful in 4m29s
iOS CI / Test (push) Failing after 4m26s
iOS CI / Test (pull_request) Failing after 12m37s
2026-03-10 16:18:43 +05:00
Admin
12bb0db5f0 feat: add libnovel-avatars bucket to minio-init and avatar_url field to pb-init
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 9s
CI / UI / Build (pull_request) Successful in 15s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 1m37s
iOS CI / Test (pull_request) Has been skipped
2026-03-10 16:12:17 +05:00
Admin
5ec1773768 chore: remove iOS release and deploy workflows (releasing locally)
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 10s
CI / Scraper / Test (pull_request) Successful in 9s
CI / UI / Build (pull_request) Successful in 15s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Failing after 1m31s
iOS CI / Test (push) Has been skipped
iOS CI / Build (pull_request) Failing after 1m26s
iOS CI / Test (pull_request) Has been skipped
2026-03-10 16:09:16 +05:00
Admin
fb8f1dfe25 feat: add avatar upload support (MinIO bucket, PocketBase field, SvelteKit API, iOS ProfileView)
Some checks failed
CI / Scraper / Lint (push) Successful in 15s
CI / Scraper / Test (push) Successful in 19s
CI / UI / Build (push) Successful in 21s
CI / Scraper / Lint (pull_request) Successful in 14s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 1m32s
iOS CI / Test (pull_request) Has been skipped
CI / UI / Docker Push (push) Successful in 6m42s
CI / Scraper / Docker Push (push) Successful in 7m12s
2026-03-10 16:08:38 +05:00
Admin
3a2d113b1b feat(ios): polish Home screen and reader UI
All checks were successful
CI / UI / Build (pull_request) Successful in 16s
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Lint (pull_request) Successful in 20s
CI / Scraper / Test (pull_request) Successful in 19s
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 2m14s
iOS CI / Build (pull_request) Successful in 2m1s
iOS CI / Test (push) Successful in 5m27s
iOS CI / Test (pull_request) Successful in 8m54s
Home:
- Hero card redesign: deeper amber-tinted gradient, taller cover (96×138), inline progress bar + completion % text above CTA
- Continue Reading shelf: replace chapter badge with circular progress arc ring (amber) containing chapter number
- Stats strip: add SF Symbol icons (books, alignleft, bookmark) above each stat value

Reader:
- Strip duplicate chapter-number/date/title prefix that novelfire embeds at top of HTML body
- Bottom chrome: pill-shaped prev/next chapter buttons (amber fill for next, muted for prev), placeholder spacers keep Listen button centered
- Chapter title page: show 'N% through' alongside date label, animated swipe-hint arrow that pulses once on appear
- Progress bar: 2px → 1px for a more refined look
2026-03-10 15:25:01 +05:00
Admin
0dcfdff65b feat(ios): Apple Books full player layout redesign
Some checks failed
CI / Scraper / Test (pull_request) Successful in 8s
CI / Scraper / Lint (pull_request) Successful in 14s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 21s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 1m45s
iOS CI / Build (pull_request) Successful in 3m56s
iOS CI / Test (push) Failing after 3m41s
iOS CI / Test (pull_request) Failing after 10m29s
- Outer GeometryReader for adaptive cover sizing (min of width-56 / height*0.42)
- Generating state: cover dims with 0.45 overlay + spinner; seek bar stays visible at opacity 0.3
- Title block: left-aligned with auto-next infinity.circle toggle on far right
- Removed ellipsis menu, year/cache metadata row, showingSpeedMenu state var
- New PlayerSecondaryButton and PlayerChapterSkipButton private helper structs
- Bottom toolbar: AirPlay | Speed | chevron.down | list.bullet | moon (44pt touch targets)
- ignoresSafeArea moved to outermost GeometryReader level
2026-03-09 23:53:03 +05:00
Admin
1766011b47 feat(ios): reader ToC drawer, swipe chapters, scroll mode, library filters
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 12s
CI / Scraper / Test (pull_request) Successful in 15s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 25s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 1m39s
iOS CI / Build (pull_request) Successful in 1m29s
iOS CI / Test (push) Has been cancelled
iOS CI / Test (pull_request) Successful in 5m14s
- Reader: list.bullet ToC button in top chrome opens ChaptersListSheet
- Reader: swipe right on title page → prev chapter, swipe left on end page → next chapter
- Reader: scroll mode toggle in settings panel (ReaderSettings.scrollMode); ScrollReaderContent for continuous layout
- Library: segmented All/In Progress/Completed filter
- Library: genre filter chips derived from book.genres, amber fill when active
- Library: completed books show checkmark badge + Finished label
- Library: context-aware empty state messages per filter combination
- Player: prev/next chapter buttons in mini + full player, ±15s skip, sleep timer countdown, Chapter N of M label
- Player: ChaptersListSheet with 100-chapter blocks, jump bar, search filter
- Home/BookDetail/Browse: Apple Books-style redesign, zoom transitions
2026-03-09 23:46:19 +05:00
Admin
a6f800b0d7 Fix iOS bundle ID: change from cc.kalekber.libnovel to com.kalekber.LibNovel
All checks were successful
CI / Scraper / Lint (pull_request) Successful in 16s
CI / UI / Build (pull_request) Successful in 16s
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Test (pull_request) Successful in 19s
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 2m18s
iOS CI / Build (pull_request) Successful in 1m55s
iOS CI / Test (push) Successful in 4m56s
iOS CI / Test (pull_request) Successful in 5m28s
This matches the provisioning profile and App Store Connect configuration.
Also fixed ExportOptions.plist to use actual team ID instead of variable.
2026-03-09 19:59:11 +05:00
Admin
af9639af05 Fix iOS signing: use PROVISIONING_PROFILE (UUID) instead of PROVISIONING_PROFILE_SPECIFIER
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 9s
CI / Scraper / Test (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 23s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 1m49s
iOS CI / Test (pull_request) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 57s
iOS CI / Test (push) Successful in 8m50s
PROVISIONING_PROFILE accepts the UUID directly and finds the profile correctly.
PROVISIONING_PROFILE_SPECIFIER expects 'TEAM_ID/name' format which caused lookup failures.

Successfully tested locally - archive builds and signs correctly.
2026-03-09 19:33:36 +05:00
Admin
bfc08a2df2 Fix iOS signing: add CODE_SIGN_IDENTITY and PROVISIONING_PROFILE_SPECIFIER to project.yml
Some checks failed
CI / Scraper / Test (pull_request) Successful in 10s
CI / Scraper / Lint (pull_request) Successful in 14s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 22s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 1m56s
iOS CI / Build (pull_request) Successful in 1m23s
iOS Release / Release to TestFlight (push) Failing after 55s
iOS CI / Test (push) Successful in 4m14s
iOS CI / Test (pull_request) Successful in 8m9s
The issue was that these settings need to be target-specific in project.yml,
not passed globally via xcodebuild args (which would conflict with SPM dependencies).

Successfully tested locally - archive builds and signs correctly.
2026-03-09 19:14:01 +05:00
Admin
dc3bc3ebf2 Use provisioning profile UUID instead of name for manual signing
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 14s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Test (pull_request) Successful in 17s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 1m31s
iOS CI / Test (pull_request) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 57s
iOS CI / Test (push) Successful in 4m29s
2026-03-09 19:02:17 +05:00
Admin
e9d7293d37 Switch to manual code signing for CI (automatic signing requires Apple ID)
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 15s
CI / Scraper / Test (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 16s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 57s
2026-03-09 18:57:21 +05:00
Admin
410af8f236 Fix iOS build: use update_code_signing_settings and explicit -allowProvisioningUpdates flags
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 13s
CI / UI / Build (pull_request) Successful in 15s
CI / Scraper / Test (pull_request) Successful in 18s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 58s
2026-03-09 18:52:04 +05:00
Admin
264c00c765 fix: switch to automatic signing for archive, manual for export
Some checks failed
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
CI / Scraper / Test (pull_request) Successful in 15s
CI / Scraper / Lint (pull_request) Successful in 16s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS Release / Release to TestFlight (push) Failing after 1m3s
2026-03-09 18:37:15 +05:00
Admin
e4c72011eb fix: extract and use actual profile name from mobileprovision file
Some checks failed
CI / Scraper / Test (pull_request) Successful in 13s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Lint (pull_request) Successful in 18s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 1m3s
2026-03-09 18:27:29 +05:00
Admin
6365b14ece fix: use update_code_signing_settings to configure provisioning profile
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 13s
CI / UI / Build (pull_request) Successful in 17s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 1m1s
2026-03-09 18:23:44 +05:00
Admin
7da5582075 fix: add manual code signing args to build_app in fastlane
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 27s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 54s
2026-03-09 18:18:30 +05:00
Admin
dae841e317 fix: set USER and locale environment variables for xcodegen
Some checks failed
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
CI / Scraper / Test (pull_request) Successful in 16s
CI / UI / Build (pull_request) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 19s
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS Release / Release to TestFlight (push) Failing after 43s
2026-03-09 18:16:25 +05:00
Admin
16b2bfffa6 fix: use correct path for xcodegen in fastlane
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 11s
CI / Scraper / Test (pull_request) Successful in 18s
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
CI / UI / Build (pull_request) Successful in 28s
CI / UI / Docker Push (pull_request) Has been skipped
iOS Release / Release to TestFlight (push) Failing after 25s
2026-03-09 18:14:33 +05:00
Admin
57be674f44 feat: use rbenv to install Ruby 3.2.2 for fastlane
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 17s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 21s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 1m51s
2026-03-09 16:12:02 +05:00
Admin
93390fab64 fix: install fastlane via Homebrew instead of bundler
Some checks failed
CI / Scraper / Test (pull_request) Successful in 8s
CI / Scraper / Lint (pull_request) Successful in 19s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 23s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 11s
iOS CI / Test (pull_request) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
2026-03-09 16:11:16 +05:00
Admin
072517135f fix: use system Ruby instead of ruby/setup-ruby action
Some checks failed
CI / Scraper / Test (pull_request) Successful in 14s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
CI / Scraper / Lint (pull_request) Successful in 17s
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
CI / UI / Build (pull_request) Successful in 22s
CI / UI / Docker Push (pull_request) Has been skipped
iOS Release / Release to TestFlight (push) Failing after 1m27s
2026-03-09 16:08:25 +05:00
Admin
fe7c7acbb7 feat: migrate iOS release to fastlane for simplified code signing
Some checks failed
CI / Scraper / Test (pull_request) Successful in 9s
CI / Scraper / Lint (pull_request) Successful in 12s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 26s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 3m58s
2026-03-09 15:28:29 +05:00
Admin
d4cce915d9 fix: explicitly set PROVISIONING_PROFILE with sdk filter for iphoneos only
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Test (pull_request) Successful in 18s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 1m37s
iOS Release / Release to TestFlight (push) Failing after 26s
iOS CI / Build (pull_request) Successful in 1m30s
iOS CI / Test (push) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
2026-03-09 15:23:26 +05:00
Admin
ac24e86f7d fix: use automatic provisioning with -allowProvisioningUpdates
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 14s
CI / UI / Build (pull_request) Successful in 17s
CI / Scraper / Test (pull_request) Successful in 18s
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 2m4s
iOS Release / Release to TestFlight (push) Failing after 32s
iOS CI / Build (pull_request) Successful in 1m39s
iOS CI / Test (push) Successful in 4m31s
iOS CI / Test (pull_request) Successful in 7m44s
2026-03-09 14:37:40 +05:00
Admin
e9bb387f71 fix: use PROVISIONING_PROFILE with UUID and -allowProvisioningUpdates flag
Some checks failed
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 14s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 26s
CI / UI / Docker Push (pull_request) Has been skipped
iOS Release / Release to TestFlight (push) Failing after 35s
2026-03-09 14:35:00 +05:00
Admin
d7319b3f7c fix: use PROVISIONING_PROFILE_SPECIFIER with profile name for manual signing
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 7s
CI / Scraper / Test (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
CI / UI / Build (pull_request) Successful in 22s
CI / UI / Docker Push (pull_request) Has been skipped
iOS Release / Release to TestFlight (push) Failing after 59s
2026-03-09 14:32:10 +05:00
Admin
f380c85815 ci: add p12 import diagnostic output
Some checks failed
CI / Scraper / Test (pull_request) Successful in 9s
CI / Scraper / Lint (pull_request) Successful in 12s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 25s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 2m10s
iOS Release / Release to TestFlight (push) Failing after 35s
iOS CI / Build (pull_request) Successful in 1m34s
iOS CI / Test (push) Successful in 4m29s
iOS CI / Test (pull_request) Has been cancelled
2026-03-09 14:20:28 +05:00
Admin
9d1b340b83 ci: debug keychain identities and preserve existing keychains in search list
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 12s
CI / Scraper / Test (pull_request) Successful in 14s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 15s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 28s
2026-03-09 14:03:15 +05:00
Admin
a307ddc9f5 ci: fix CODE_SIGN_IDENTITY to 'Apple Distribution' to match modern certificate name
Some checks failed
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
CI / Scraper / Lint (pull_request) Successful in 13s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Test (pull_request) Successful in 18s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS Release / Release to TestFlight (push) Failing after 34s
iOS CI / Build (pull_request) Successful in 2m23s
iOS CI / Test (pull_request) Successful in 4m17s
2026-03-09 14:01:40 +05:00
Admin
004d1b6d9d fix: correct bundle ID to com.kalekber.LibNovel to match provisioning profile
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 17s
CI / Scraper / Test (pull_request) Successful in 19s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 28s
2026-03-09 13:57:33 +05:00
Admin
7f20411f50 ci: add profile metadata debug output to diagnose provisioning mismatch
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 14s
CI / Scraper / Test (pull_request) Successful in 18s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 25s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 35s
2026-03-09 13:48:32 +05:00
Admin
6e6c581904 ci: pass team ID as just arg and add -destination generic/platform=iOS to archive
Some checks failed
CI / Scraper / Test (pull_request) Successful in 17s
CI / UI / Build (pull_request) Successful in 17s
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Lint (pull_request) Successful in 20s
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 32s
iOS CI / Test (pull_request) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
2026-03-09 13:45:04 +05:00
Admin
cecedc8687 ci: add debug output to archive step
Some checks failed
CI / Scraper / Test (pull_request) Successful in 8s
CI / Scraper / Lint (pull_request) Successful in 14s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 21s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 1m47s
iOS Release / Release to TestFlight (push) Failing after 26s
iOS CI / Build (pull_request) Successful in 1m38s
iOS CI / Test (push) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
2026-03-09 13:36:33 +05:00
Admin
a88e98a436 ci: add Release signing settings to project.yml so xcodegen bakes them into pbxproj
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 11s
CI / Scraper / Test (pull_request) Successful in 17s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 24s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 1m40s
iOS Release / Release to TestFlight (push) Failing after 29s
iOS CI / Build (pull_request) Successful in 1m32s
iOS CI / Test (push) Has been cancelled
iOS CI / Test (pull_request) Successful in 3m54s
2026-03-09 13:31:36 +05:00
Admin
d3ae86d55b ci: pass profile UUID as just argument to avoid unbound variable in recipe shell
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 17s
CI / UI / Build (pull_request) Successful in 16s
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Test (pull_request) Successful in 19s
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 39s
2026-03-09 13:25:06 +05:00
Admin
5ad5c2dbce ci: resolve PROFILE_UUID in archive step shell to fix empty env var
Some checks failed
CI / Scraper / Test (pull_request) Successful in 9s
CI / Scraper / Lint (pull_request) Successful in 14s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 22s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 2m3s
iOS CI / Build (pull_request) Successful in 1m34s
iOS Release / Release to TestFlight (push) Failing after 22s
iOS CI / Test (push) Successful in 4m13s
iOS CI / Test (pull_request) Has been cancelled
2026-03-09 13:15:42 +05:00
Admin
0de91dcc0c ci: fix archive — install profile by UUID and pass PROVISIONING_PROFILE to xcodebuild
Some checks failed
CI / Scraper / Test (pull_request) Successful in 9s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
CI / Scraper / Lint (pull_request) Successful in 22s
CI / UI / Build (pull_request) Successful in 21s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Successful in 1m50s
iOS Release / Release to TestFlight (push) Failing after 32s
iOS CI / Test (pull_request) Has been cancelled
2026-03-09 13:08:30 +05:00
Admin
8e3e9ef31d ci: serialize ios and ios-release on shared macos runner concurrency group
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 17s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 22s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
2026-03-09 13:05:26 +05:00
Admin
3c5edd5742 ci: fix ios archive signing — pass CODE_SIGN_STYLE=Manual and DEVELOPMENT_TEAM to xcodebuild
Some checks failed
CI / Scraper / Test (pull_request) Successful in 10s
CI / Scraper / Lint (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 22s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Successful in 2m57s
iOS Release / Release to TestFlight (push) Failing after 43s
iOS CI / Test (pull_request) Has been cancelled
2026-03-09 12:57:20 +05:00
Admin
2142e82fe4 ci: set USER=runner env var to fix xcodegen NSUserName() crash on daemon runner
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 26s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 1m39s
iOS CI / Build (pull_request) Successful in 1m28s
iOS Release / Release to TestFlight (push) Failing after 27s
iOS CI / Test (push) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
2026-03-09 12:38:36 +05:00
Admin
88cde88f69 ci: install just via brew on mac runner
Some checks failed
iOS CI / Build (push) Failing after 8s
iOS CI / Test (push) Has been skipped
CI / Scraper / Test (pull_request) Successful in 9s
CI / Scraper / Lint (pull_request) Successful in 14s
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS Release / Release to TestFlight (push) Failing after 12s
CI / UI / Build (pull_request) Successful in 21s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 10s
iOS CI / Test (pull_request) Has been skipped
2026-03-09 12:37:09 +05:00
Admin
ffcc3981f2 ci: skip just install if already present on host runner
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 9s
iOS CI / Build (push) Failing after 9s
iOS CI / Test (push) Has been skipped
CI / Scraper / Test (pull_request) Successful in 17s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 22s
iOS Release / Release to TestFlight (push) Failing after 13s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 10s
iOS CI / Test (pull_request) Has been skipped
2026-03-09 12:36:18 +05:00
Admin
a7b4694e60 ci: fix mac runner config YAML and simplify setup to copy static config instead of generate+patch
Some checks failed
CI / Scraper / Test (pull_request) Successful in 9s
CI / Scraper / Lint (pull_request) Successful in 17s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 21s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 11s
iOS CI / Test (pull_request) Has been skipped
iOS Release / Release to TestFlight (push) Has been cancelled
2026-03-09 12:33:00 +05:00
Admin
8c895c6ba1 ci: fix act_runner download URLs for arm64 and amd64 darwin
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 18s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 26s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 58s
iOS CI / Test (pull_request) Has been skipped
2026-03-09 12:31:02 +05:00
Admin
83059c8a9d ci: replace python config patching with awk in setup_runner_mac.sh 2026-03-09 12:20:59 +05:00
Admin
b54ebf60b5 ci: add macOS act_runner config and setup script for iOS host runner 2026-03-09 12:19:01 +05:00
Admin
e027afe89d ci: remove just from Linux workflows, drop redundant scraper build job, consolidate to Docker Hub
Some checks failed
CI / Scraper / Test (push) Successful in 9s
CI / Scraper / Lint (push) Successful in 13s
CI / Scraper / Lint (pull_request) Successful in 9s
CI / UI / Build (push) Successful in 22s
CI / Scraper / Test (pull_request) Successful in 18s
CI / UI / Build (pull_request) Successful in 17s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (push) Has been cancelled
CI / Scraper / Docker Push (push) Has been cancelled
iOS CI / Build (push) Failing after 2s
iOS CI / Test (push) Has been skipped
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
iOS Release / Release to TestFlight (push) Failing after 2s
2026-03-08 22:42:31 +05:00
Admin
9fc2054e36 ci: disable deploy workflow and add Docker Hub push to ui and scraper CI
Some checks failed
CI / Scraper / Test (pull_request) Successful in 11s
CI / Scraper / Lint (pull_request) Successful in 18s
CI / UI / Build (pull_request) Successful in 17s
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Build (pull_request) Failing after 2m29s
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
2026-03-08 22:39:16 +05:00
Admin
9a43b2190e fix: update NewHybridStore calls in integration and e2e tests to pass slog.Default()
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 0s
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Lint (pull_request) Successful in 24s
CI / UI / Build (pull_request) Successful in 26s
CI / Scraper / Build (pull_request) Failing after 2m25s
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
2026-03-08 22:37:29 +05:00
Admin
5a7d7ce3b9 fix: remove non-existent created field from auth/me and fix html declaration order in chapter page
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
CI / Scraper / Lint (pull_request) Failing after 17s
CI / UI / Build (pull_request) Successful in 25s
CI / Scraper / Test (pull_request) Successful in 27s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 1s
iOS CI / Test (pull_request) Has been skipped
2026-03-08 22:28:08 +05:00
Admin
ce3eef1298 ci: install just via install.sh instead of setup-just action
Some checks failed
CI / Scraper / Test (pull_request) Successful in 13s
CI / Scraper / Lint (pull_request) Failing after 19s
CI / Scraper / Build (pull_request) Has been skipped
CI / UI / Build (pull_request) Failing after 25s
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
2026-03-08 22:24:33 +05:00
Admin
5d9b41bcf2 ci: fix setup-just auth and split ios release into separate workflow
Some checks failed
CI / Scraper / Test (pull_request) Failing after 7s
CI / UI / Build (pull_request) Failing after 8s
CI / Scraper / Lint (pull_request) Failing after 10s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
2026-03-08 22:21:43 +05:00
Admin
47268dea67 ci: add TestFlight release pipeline and workflow improvements
Some checks failed
CI / Scraper / Lint (pull_request) Failing after 7s
CI / UI / Build (pull_request) Failing after 7s
CI / Scraper / Test (pull_request) Failing after 11s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Release to TestFlight (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 1s
iOS CI / Release to TestFlight (pull_request) Has been skipped
iOS CI / Test (pull_request) Has been skipped
2026-03-08 22:10:59 +05:00
Admin
57591766f2 iOS: update tab icons, skip interval, cover radius, and mini player progress bar fix
Some checks failed
CI / UI / Build (pull_request) Failing after 6s
CI / Scraper / Test (pull_request) Failing after 6s
CI / Scraper / Lint (pull_request) Failing after 9s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
2026-03-08 19:23:14 +05:00
Admin
fa8fb96631 iOS: fix AVRoutePickerView hierarchy warning by using UIViewControllerRepresentable
Some checks failed
CI / UI / Build (pull_request) Failing after 6s
CI / Scraper / Test (pull_request) Failing after 9s
CI / Scraper / Lint (pull_request) Failing after 10s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
2026-03-08 15:45:25 +05:00
Admin
5ba84f7945 iOS: fix player menu blink and stale checkmarks by isolating high-frequency playback state
Some checks failed
CI / Scraper / Test (pull_request) Failing after 8s
CI / UI / Build (pull_request) Failing after 7s
CI / Scraper / Lint (pull_request) Failing after 12s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
Extract currentTime/duration/isPlaying into a separate PlaybackProgress
ObservableObject so the 0.5s time-observer ticks only invalidate the seek
bar and play-pause button subviews, not the entire FullPlayerView or
MiniPlayerView. Menus no longer blink or lose their checkmarks during
playback.

Also fix chapter list selection in FullPlayerView to call audioPlayer.load()
directly instead of relying on skip notifications (which only worked when
ChapterReaderView was open).
2026-03-08 15:42:39 +05:00
Admin
2793ad8cfa iOS: codebase cleanup — remove dead code, unify design, deduplicate patterns
Some checks failed
CI / UI / Build (pull_request) Failing after 7s
CI / Scraper / Test (pull_request) Failing after 10s
CI / Scraper / Lint (pull_request) Failing after 10s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
- Remove swift-markdown-ui dependency (project.yml, pbxproj, Package.resolved)
- Remove all debug print() statements (APIClient, BrowseViewModel, ChapterReaderViewModel, ChapterReaderView)
- Remove dead UI stubs: isFavorited/isLiked state, heart/star buttons, empty Share/Add to Library menu items in FullPlayerView
- Remove unused audioToolbarButton toolbar builder in ChapterReaderView
- Remove unused model types: NovelListing, BookDetailData, ChapterContent
- Remove amberLight color (never referenced)
- Remove mini player interactive seek (horizontal drag/tap to seek) — progress bar is now display-only
- Fix AccentColor asset to amber #f59e0b (was orange-pink mismatch)
- Fix Discover tab icon: globe → globe.americas.fill
- Fix speed slider max: 3.0 → 2.0 in ProfileView
- Fix yearText: use static DateFormatter instead of allocating on every access
- Fix ChangePasswordView.save(): DispatchQueue.main.asyncAfter → Task.sleep
- Deduplicate navigationDestination blocks via .appNavigationDestination() View extension
- Deduplicate .alert(Error) pattern via .errorAlert() View extension
2026-03-08 15:30:43 +05:00
Admin
e43699747d feat: make prev/next chapter buttons absolute navigation
Previously, prev/next buttons were relative to the currently playing
chapter and only worked for one navigation step. They relied on
audioPlayer.prevChapter and audioPlayer.nextChapter which were set
when loading audio and never updated.

Now buttons use absolute chapter navigation:
- Previous: always navigate to (current chapter - 1)
- Next: always navigate to (current chapter + 1)
- Bounded by chapter 1 and max chapter number
- Works continuously without stopping after one navigation

Added AudioPlayerService.absolutePrevChapter and .absoluteNextChapter
computed properties that calculate the prev/next chapter numbers based
on the current chapter and total chapters list.

Updated both mini player and full player to use absolute navigation.
2026-03-08 14:20:49 +05:00
Admin
1e85f1c0bc fix: improve seek gesture priority in mini player
Change seek gesture from .gesture to .highPriorityGesture to ensure
horizontal press-and-drag seeking takes precedence over the vertical
swipe gesture. This fixes unreliable seeking when dragging on the
mini player progress bar.
2026-03-08 14:16:05 +05:00
Admin
0c2349f259 iOS: add floating audio button when player is not active
- Moved audio start button from top-right toolbar to floating bottom-right position
- Only shows floating button when audio player is not active
- Button styled as amber pill with 'Listen' text and play icon
- More accessible placement for quick audio playback start
- Positioned 20pt from bottom and right edges with shadow
2026-03-08 14:07:55 +05:00
Admin
c9252b5953 iOS: update app icon to modern gradient design
- Created 1024x1024 app icon with amber/orange gradient background
- Added white open book icon in center with pages and spine
- Gradient matches app's amber accent color theme
- Clean, minimal, modern design suitable for iOS
2026-03-08 14:06:29 +05:00
Admin
7efeee3fc2 iOS: redesign mini player with prev button and interactive progress bar
- Added previous chapter button (matching next button style)
- Made progress bar interactive - now occupies full background of mini player
- Horizontal drag/tap on progress bar seeks audio (shows amber overlay while seeking)
- Progress bar now uses full pill shape as background instead of small bottom border
- Tightened button spacing (12pt instead of 16pt) to fit prev/next buttons
- Changed vertical drag to simultaneousGesture to not conflict with horizontal seek
- Buttons slightly smaller (40pt) to accommodate prev button in layout
2026-03-08 13:49:40 +05:00
Admin
9a05708019 iOS: reduce bottom padding in full player to occupy all space
- Reduced bottom padding from 24pt to 8pt on bottom toolbar
- Reduced spacer above toolbar from 24pt to 16pt
- Added .ignoresSafeArea(edges: .bottom) to VStack to extend to screen bottom
- Player now uses full screen height without excess bottom padding
2026-03-08 13:44:04 +05:00
Admin
24cb18e0fe iOS: optimize chapters list to render at current chapter instantly
- Added custom init to ChaptersListSheet that sets scrollPosition state before view appears
- Removed onAppear scroll position update (now set in init)
- List renders directly at current chapter position without any scroll animation
- Better performance for books with 1000+ chapters (no initial render + animate delay)
2026-03-08 13:42:23 +05:00
Admin
71ba882858 iOS: improve mini player progress indicator to wrap around pill shape
- Changed progress bar from RoundedRectangle to Capsule for rounded ends
- Added horizontal padding so indicator wraps nicely around bottom corners
- Progress bar now feels like an integrated part of the pill-shaped mini player
2026-03-08 13:40:53 +05:00
Admin
c35f099f50 iOS: wire prev/next chapter buttons and add prefetch indicator
- Full player prev/next buttons now dismiss the player before navigating (allows user to see chapter change)
- Added amber loading indicator on next chapter button when prefetching audio (both mini and full player)
- Indicator appears as small ProgressView in bottom-right corner of forward button
2026-03-08 13:39:38 +05:00
Admin
4df287ace4 iOS: fix sleep timer and AirPlay button size issues
- Fix chapter-based sleep timer not resetting when auto-advancing to next chapter (sleepTimerStartChapter now updates in handlePlaybackFinished)
- Fix AirPlay button being too large compared to other toolbar icons (constrain to 22×22)
2026-03-08 13:36:59 +05:00
Admin
0df45de2b6 iOS: fix chapters list to open at current chapter without auto-scroll animation
Replace ScrollViewReader with scrollPosition to instantly show the current chapter without visible scrolling. This scales better for books with 1000+ chapters.
2026-03-08 13:34:33 +05:00
Admin
825fb04c0d iOS: add functional AirPlay and sleep timer to full player
Some checks failed
CI / UI / Build (pull_request) Failing after 6s
CI / Scraper / Test (pull_request) Failing after 7s
CI / Scraper / Lint (pull_request) Failing after 8s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
AirPlay:
- Replaced placeholder AirPlay button with AVRoutePickerView
- Users can now cast audio to AirPlay devices
- Icon changes color when AirPlay is active

Sleep Timer:
- Added sleep timer sheet with multiple options
- Chapter-based: 1, 2, 3, or 4 chapters
- Time-based: 20 mins, 40 mins, 1 hour, 2 hours
- Moon icon fills when timer is active and turns amber
- Timer stops playback when target is reached
- Chapter-based timer tracks chapters played from start
- Time-based timer uses async task with proper cancellation
- Timer is cancelled when playback stops manually
- SleepTimerOption enum supports both timer types
2026-03-08 13:25:07 +05:00
Admin
fc5cd30c93 iOS: add chapters list sheet to full player
Some checks failed
CI / UI / Build (pull_request) Failing after 6s
CI / Scraper / Test (pull_request) Failing after 6s
CI / Scraper / Lint (pull_request) Failing after 8s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
- List bullet button now opens a chapters list sheet
- ChaptersListSheet displays all chapters with scroll view
- Current chapter highlighted with amber badge and background
- Auto-scrolls to current chapter when opened
- Chapter selection uses skip notifications to navigate
- Supports both .medium and .large presentation sizes
- Shows "Now Playing" label on current chapter
- Clean list design with chapter numbers and titles
2026-03-08 13:18:07 +05:00
Admin
37bd73651a iOS: replace shuffle/repeat with time skip and add chapter navigation to full player
Some checks failed
CI / Scraper / Test (pull_request) Failing after 8s
CI / UI / Build (pull_request) Failing after 8s
CI / Scraper / Lint (pull_request) Failing after 11s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 1s
iOS CI / Test (pull_request) Has been skipped
- Leftmost button: 15-second backward skip (gobackward.15)
- Second button: previous chapter navigation (backward.end.fill)
- Center: play/pause (unchanged)
- Fourth button: next chapter navigation (forward.end.fill)
- Rightmost button: 15-second forward skip (goforward.15)
- Added prevChapter tracking in AudioPlayerService
- Added skipToPrevChapter notification support
- Chapter nav buttons disabled when no prev/next available (0.5 opacity)
- Updated load() signature to accept prevChapter parameter
- Auto-next properly tracks prevChapter when advancing chapters
2026-03-08 13:14:26 +05:00
Admin
466e289b68 iOS: redesign mini player with pill shape, larger cover, and chapter skip button
- Fully pill-shaped design (cornerRadius 40) with dark glassmorphic background
- Larger cover thumbnail (56×56 with 12pt corner radius)
- Improved layout: cover + chapter/book titles + play/pause + next chapter skip
- Progress indicator moved to bottom 3pt amber border
- Removed dismiss X button - swipe down to dismiss instead
- Added swipe gestures: up to expand (0.4x resistance), down to dismiss (0.8x resistance)
- Dismiss animation with fade and scale effects
- Skip forward button navigates to next chapter (disabled when unavailable)
- Updated mini player height to 92pt in AppLayout
2026-03-08 13:14:18 +05:00
Admin
bb604019fc iOS: remove redundant Now Playing header and Done button from full player
Some checks failed
CI / Scraper / Lint (pull_request) Failing after 7s
CI / UI / Build (pull_request) Failing after 8s
CI / Scraper / Test (pull_request) Failing after 10s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
2026-03-07 23:08:35 +05:00
Admin
0745178d9e iOS: natural swipe-to-expand/dismiss player with live drag tracking
Some checks failed
CI / UI / Build (pull_request) Failing after 7s
CI / Scraper / Test (pull_request) Failing after 7s
CI / Scraper / Lint (pull_request) Failing after 10s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
2026-03-07 23:07:25 +05:00
Admin
603cd2bb02 iOS: add swipe gestures to mini player — up to expand, down to dismiss
Some checks failed
CI / UI / Build (pull_request) Failing after 6s
CI / Scraper / Test (pull_request) Failing after 8s
CI / Scraper / Lint (pull_request) Failing after 10s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
2026-03-07 23:02:57 +05:00
Admin
228d4902bb iOS: fix mini player overlap, empty cover square, chapter nav, and UI polish
Some checks failed
CI / UI / Build (pull_request) Failing after 6s
CI / Scraper / Lint (pull_request) Failing after 9s
CI / Scraper / Test (pull_request) Failing after 8s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
- Add safeAreaInset on ChapterReaderView ScrollView so Prev/Next buttons
  clear the mini player bar when audio is active
- Fix mini player height reservation (66pt constant via AppLayout)
- Add AsyncCoverImage(isBackground:) to suppress rounded-rect placeholder
  showing through blurred hero background (empty square bug)
- Reduce chapter page size 100→50 on iOS
- Fix ChapterRow date/chevron layout: minLength spacer + fixedSize date
- Add String.strippingTrailingDate() extension; apply in MiniPlayer,
  FullPlayer, and ChapterReader header to clean up stale concatenated titles
- Make Prev/Next buttons equal-width (maxWidth: .infinity) for easier tapping
2026-03-07 22:55:26 +05:00
Admin
884c82b2c3 iOS: fix audio generation to handle async 202 job flow + increase chapter list font size
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
CI / UI / Build (pull_request) Failing after 7s
CI / Scraper / Test (pull_request) Failing after 8s
CI / Scraper / Lint (pull_request) Failing after 10s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
- Replace AudioGenerateResponse with AudioTriggerResponse to handle both 200 (cached) and 202 (async job) responses
- Add pollAudioStatus() in APIClient that polls /api/audio/status every 2s until done or failed
- Update generateAudio() and prefetchNext() in AudioPlayerService to poll instead of expecting a synchronous URL response
- Bump chapter list font sizes: number xs→sm, title sm→base, date/badge xs→sm, row padding py-2→py-2.5
2026-03-07 20:33:19 +05:00
Admin
c6536d5b9f Add /admin/audio-jobs page for audio generation job history
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 0s
CI / Scraper / Test (pull_request) Failing after 6s
CI / UI / Build (pull_request) Failing after 6s
CI / Scraper / Lint (pull_request) Failing after 11s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
New page mirrors the scrape tasks pattern: loads audio_jobs from
PocketBase via a new listAudioJobs() helper, shows a filterable table
(slug, chapter, voice, status, started, duration, error), and live-polls
every 3s while any job is pending or generating. Also fixes the
/admin/audio nav active-state check (was startsWith, now exact match)
to prevent it from matching /admin/audio-jobs.
2026-03-07 20:20:48 +05:00
Admin
460e7553bf iOS: fix audio polling, AVPlayer stability, and misc UX issues
Some checks failed
CI / UI / Build (pull_request) Failing after 5s
CI / Scraper / Test (pull_request) Failing after 14s
CI / Scraper / Lint (pull_request) Failing after 15s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
- AudioPlayerService: replace loading/generating states with a single
  .generating state; presign fast path before triggering TTS; fix URL
  resolution for relative paths; cache cover artwork to avoid re-downloads;
  fix duration KVO race (durationObserver); use toleranceBefore/After:zero
  for accurate seeking; prefetch next chapter unconditionally (not just when
  autoNext is on); handle auto-next internally on playback finish
- APIClient: fix URL construction (appendingPathComponent encodes slashes);
  add verbose debug logging for all requests/responses/decoding errors;
  fix sessions() to unwrap {sessions:[]} envelope; fix BrowseResponse
  CodingKey hasNext → camelCase
- AudioGenerateResponse: update to synchronous {url, filename} shape
- Models: remove redundant AudioStatus enum; remove CodingKeys from
  UserSettings (server now sends camelCase)
- Views: fix alert bindings (.constant → proper two-way Binding); add
  error+retry UI to BrowseView; add pull-to-refresh to browse list;
  fix ChapterReaderView to show error state and use dynamic WKWebView
  height; fix HomeView HStack alignment; only treat audio as current
  if the player isActive; suppress CancellationError from error UI
2026-03-07 20:13:40 +05:00
Admin
89f0dfb113 Add async audio generation: job tracking in PocketBase + UI polling
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
CI / UI / Build (pull_request) Failing after 7s
CI / Scraper / Lint (pull_request) Failing after 8s
CI / Scraper / Test (pull_request) Failing after 9s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
Replace blocking POST /api/audio with a non-blocking 202 flow: the Go
handler immediately enqueues a job in a new `audio_jobs` PocketBase
collection and returns {job_id, status}. A background goroutine runs
the actual Kokoro TTS work and updates job status (pending → generating
→ done/failed). A new GET /api/audio/status/{slug}/{n} endpoint lets
clients poll progress. The SvelteKit proxy and AudioPlayer.svelte are
updated to POST, then poll the status route every 2s until done.
2026-03-07 20:12:08 +05:00
Admin
88644341d8 Add app icon and web favicons
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
CI / UI / Build (pull_request) Failing after 6s
CI / Scraper / Test (pull_request) Failing after 9s
CI / Scraper / Lint (pull_request) Failing after 11s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 4s
iOS CI / Test (pull_request) Has been skipped
- Generated open-book icon on amber background (matches accent color #f59e0b)
- iOS: icon-1024.png in AppIcon.appiconset + Contents.json updated to reference it
- Web: favicon.ico (16+32), favicon-16.png, favicon-32.png, apple-touch-icon.png
  (180px), icon-192.png, icon-512.png added to ui/static/
- app.html: added full set of <link> favicon tags (ico, png, apple-touch-icon)
2026-03-07 18:27:00 +05:00
Admin
992eb823f2 Unify CI and justfile: all workflows call just recipes
Some checks failed
CI / Scraper / Test (pull_request) Failing after 9s
CI / UI / Build (pull_request) Failing after 9s
CI / Scraper / Lint (pull_request) Failing after 12s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
- justfile: extract ios_scheme/ios_sim/ios_spm/runner_temp variables; add
  ios-resolve (SPM dep cache), ios-archive, ios-export recipes; pipe xcodebuild
  through xcpretty with raw fallback in ios-build/ios-test
- ios.yaml: replace raw xcodebuild calls with just ios-build / just ios-test;
  install just via brew; uncommented archive job stub now calls just ios-archive
  && just ios-export; add justfile to path trigger
- ci-scraper.yaml: install just via extractions/setup-just@v2; use just lint /
  just test; fix upload-artifact v3 -> v4; add justfile to path trigger
- ci-ui.yaml: install just; use just ui-install / just ui-check / just ui-build;
  add justfile to path trigger
2026-03-07 18:23:38 +05:00
Admin
f51113a2f8 Add iOS app, SvelteKit JSON API endpoints, and Gitea CI workflow
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 0s
CI / Scraper / Test (pull_request) Successful in 11s
CI / UI / Build (pull_request) Failing after 14s
CI / Scraper / Lint (pull_request) Successful in 23s
CI / Scraper / Build (pull_request) Successful in 24s
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
- 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
2026-03-07 18:17:51 +05:00
Admin
1eb70e9b9b Add session management: track active sessions, show on profile, allow revocation
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
Deploy / Cleanup Preview (push) Has been skipped
CI / UI / Build (pull_request) Failing after 13s
CI / Scraper / Lint (pull_request) Successful in 19s
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Build (pull_request) Successful in 12s
- Add user_sessions PocketBase collection (user_id, session_id, user_agent, ip, created/last_seen)
- Extend auth token format to include a per-login authSessionId (4th segment)
- Hook validates authSessionId against DB on each request; revoked sessions are cleared immediately
- Login/register create a session record capturing user-agent and IP
- Profile page shows all active sessions with current session highlighted; per-session End/Sign out buttons
- GET /api/sessions and DELETE /api/sessions/[id] endpoints for client-side revocation
- Backward compatible: legacy 3-segment tokens pass through without DB check
2026-03-07 11:53:16 +05:00
Admin
70dd14e5c8 audio player: double chapter drawer max height to 32rem
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
CI / UI / Build (pull_request) Failing after 11s
CI / Scraper / Test (pull_request) Successful in 16s
CI / Scraper / Lint (pull_request) Successful in 20s
CI / Scraper / Build (pull_request) Successful in 14s
2026-03-06 20:42:01 +05:00
Admin
8096827c78 chapter page: show book title in nav header, simplify back button and heading 2026-03-06 20:40:43 +05:00
Admin
669fd765ee Display word count on chapter page
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
CI / Scraper / Test (pull_request) Successful in 9s
CI / UI / Build (pull_request) Failing after 14s
CI / Scraper / Lint (pull_request) Successful in 18s
CI / Scraper / Build (pull_request) Successful in 9s
2026-03-06 20:36:27 +05:00
Admin
314af375d5 Remove chapter date from chapter page; show 50 chapters per page on mobile, 100 on desktop
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
CI / Scraper / Lint (pull_request) Successful in 11s
CI / Scraper / Test (pull_request) Successful in 20s
CI / UI / Build (pull_request) Successful in 21s
CI / Scraper / Build (pull_request) Successful in 9s
2026-03-06 20:34:53 +05:00
Admin
20c45e2676 Mobile hero: move CTAs to full-width row below cover; fix chapter dates still showing on mobile
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
CI / Scraper / Test (pull_request) Successful in 9s
CI / Scraper / Lint (pull_request) Successful in 19s
CI / UI / Build (pull_request) Successful in 21s
CI / Scraper / Build (pull_request) Successful in 19s
2026-03-06 20:17:57 +05:00
Admin
09981a5f4d Add Disclaimer, Privacy, and DMCA pages; update footer with legal links and copyright
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
CI / Scraper / Lint (pull_request) Successful in 12s
CI / Scraper / Test (pull_request) Successful in 16s
CI / UI / Build (pull_request) Successful in 20s
CI / Scraper / Build (pull_request) Successful in 17s
2026-03-06 20:15:26 +05:00
Admin
de9e0b4246 Fix mobile: hide chapter dates on small screens, fix summary word-break when expanded
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
CI / Scraper / Test (pull_request) Successful in 11s
CI / Scraper / Lint (pull_request) Successful in 17s
CI / UI / Build (pull_request) Successful in 21s
CI / Scraper / Build (pull_request) Successful in 10s
2026-03-06 20:04:17 +05:00
Admin
a72c1f6b52 Redesign book detail page: hero with blurred cover bg, CTA hierarchy, collapsible admin panel
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Lint (pull_request) Successful in 19s
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Build (pull_request) Successful in 16s
2026-03-06 19:59:21 +05:00
108 changed files with 12282 additions and 469 deletions

View File

@@ -2,19 +2,19 @@ name: CI / Scraper
on:
push:
branches: ["main", "master"]
branches: ["main", "master", "v2"]
paths:
- "scraper/**"
- ".gitea/workflows/ci-scraper.yaml"
pull_request:
branches: ["main", "master"]
branches: ["main", "master", "v2"]
paths:
- "scraper/**"
- ".gitea/workflows/ci-scraper.yaml"
defaults:
run:
working-directory: scraper
concurrency:
group: ${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
# ── lint & vet ───────────────────────────────────────────────────────────────
@@ -30,10 +30,10 @@ jobs:
cache-dependency-path: scraper/go.sum
- name: go vet
run: go vet ./...
- name: staticcheck
run: go tool staticcheck ./...
working-directory: scraper
run: |
go vet ./...
go vet -tags integration ./...
# ── tests ────────────────────────────────────────────────────────────────────
test:
@@ -48,29 +48,29 @@ jobs:
cache-dependency-path: scraper/go.sum
- name: Run tests
run: go test -race -count=1 -timeout=60s ./...
working-directory: scraper
run: go test -short -race -count=1 -timeout=60s ./...
# ── build binary ─────────────────────────────────────────────────────────────
build:
name: Build
# ── push to Docker Hub ───────────────────────────────────────────────────────
docker:
name: Docker Push
runs-on: ubuntu-latest
needs: [lint, test]
if: gitea.event_name == 'push'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
go-version-file: scraper/go.mod
cache-dependency-path: scraper/go.sum
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build binary
run: |
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o bin/scraper ./cmd/scraper
- name: Upload binary artifact
uses: actions/upload-artifact@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
name: scraper-linux-amd64
path: scraper/bin/scraper
retention-days: 7
context: scraper
push: true
tags: |
${{ secrets.DOCKER_USER }}/libnovel-scraper:latest
${{ secrets.DOCKER_USER }}/libnovel-scraper:${{ gitea.sha }}

View File

@@ -2,25 +2,28 @@ name: CI / UI
on:
push:
branches: ["main", "master"]
branches: ["main", "master", "v2"]
paths:
- "ui/**"
- ".gitea/workflows/ci-ui.yaml"
pull_request:
branches: ["main", "master"]
branches: ["main", "master", "v2"]
paths:
- "ui/**"
- ".gitea/workflows/ci-ui.yaml"
defaults:
run:
working-directory: ui
concurrency:
group: ${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
# ── type-check & build ───────────────────────────────────────────────────────
build:
name: Build
runs-on: ubuntu-latest
defaults:
run:
working-directory: ui
steps:
- uses: actions/checkout@v4
@@ -38,3 +41,27 @@ jobs:
- name: Build
run: npm run build
# ── push to Docker Hub ───────────────────────────────────────────────────────
docker:
name: Docker Push
runs-on: ubuntu-latest
needs: build
if: gitea.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: ui
push: true
tags: |
${{ secrets.DOCKER_USER }}/libnovel-ui:latest
${{ secrets.DOCKER_USER }}/libnovel-ui:${{ gitea.sha }}

View File

@@ -1,134 +0,0 @@
name: Deploy
on:
push:
branches:
- "**"
paths:
- "scraper/**"
- "ui/**"
- "docker-compose.yml"
pull_request:
types: [closed]
# tRPC API helper notes:
# Mutations: POST /api/trpc/<router>.<procedure>
# Body: {"0":{"json":{...input...}}}
# Header: x-api-key: <token>
# Queries: GET /api/trpc/<router>.<procedure>?batch=1&input={"0":{"json":{...input...}}}
# Header: x-api-key: <token>
# Response on success: HTTP 200, body: [{"result":{"data":{"json":{...}}}}]
jobs:
# ── production deploy (main/master only) ─────────────────────────────────────
deploy-production:
name: Deploy Production
runs-on: ubuntu-latest
if: >
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')
steps:
- name: Redeploy production stack
run: |
RESPONSE=$(curl -s -w "\n%{http_code}" \
-X POST "${{ secrets.DOKPLOY_URL }}/api/trpc/compose.redeploy" \
-H "x-api-key: ${{ secrets.DOKPLOY_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"0":{"json":{"composeId":"${{ secrets.DOKPLOY_COMPOSE_ID }}"}}}')
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | head -1)
echo "Status: $HTTP_CODE"
echo "Body: $BODY"
[ "$HTTP_CODE" = "200" ] || { echo "Redeploy failed"; exit 1; }
# ── preview deploy (feature branches) ────────────────────────────────────────
deploy-preview:
name: Deploy Preview
runs-on: ubuntu-latest
if: >
github.event_name == 'push' &&
github.ref != 'refs/heads/main' &&
github.ref != 'refs/heads/master'
steps:
- name: Sanitize branch name
id: branch
run: |
# Lowercase, replace non-alphanumeric with dashes, strip trailing dashes, max 20 chars
SUFFIX=$(echo "${{ github.ref_name }}" \
| tr '[:upper:]' '[:lower:]' \
| sed 's/[^a-z0-9]/-/g' \
| cut -c1-20 \
| sed 's/-*$//')
echo "suffix=$SUFFIX" >> $GITHUB_OUTPUT
echo "Preview suffix: $SUFFIX"
- name: Create or redeploy isolated preview stack
run: |
# compose.isolatedDeployment creates a new isolated copy of the compose stack
# suffixed with the branch name. If the stack already exists it redeploys it.
RESPONSE=$(curl -s -w "\n%{http_code}" \
-X POST "${{ secrets.DOKPLOY_URL }}/api/trpc/compose.isolatedDeployment" \
-H "x-api-key: ${{ secrets.DOKPLOY_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{\"0\":{\"json\":{\"composeId\":\"${{ secrets.DOKPLOY_COMPOSE_ID }}\",\"suffix\":\"${{ steps.branch.outputs.suffix }}\"}}}")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | head -1)
echo "Status: $HTTP_CODE"
echo "Body: $BODY"
[ "$HTTP_CODE" = "200" ] || { echo "Preview deploy failed"; exit 1; }
# ── cleanup preview on PR close ───────────────────────────────────────────────
cleanup-preview:
name: Cleanup Preview
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.action == 'closed'
steps:
- name: Sanitize branch name
id: branch
run: |
SUFFIX=$(echo "${{ github.head_ref }}" \
| tr '[:upper:]' '[:lower:]' \
| sed 's/[^a-z0-9]/-/g' \
| cut -c1-20 \
| sed 's/-*$//')
echo "suffix=$SUFFIX" >> $GITHUB_OUTPUT
echo "Cleaning up preview suffix: $SUFFIX"
- name: Search for preview compose stack by appName
id: find
run: |
# compose.search is a tRPC query (GET). We search by appName pattern.
# appName is set by Dokploy as "<base-appName>-<suffix>" for isolated deployments.
INPUT=$(python3 -c "import json,sys; print(json.dumps({'0':{'json':{'appName':'libnovel-${{ steps.branch.outputs.suffix }}','limit':5,'offset':0}}}))")
RESPONSE=$(curl -s -w "\n%{http_code}" -G \
"${{ secrets.DOKPLOY_URL }}/api/trpc/compose.search" \
-H "x-api-key: ${{ secrets.DOKPLOY_TOKEN }}" \
--data-urlencode "batch=1" \
--data-urlencode "input=$INPUT")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | head -1)
echo "Status: $HTTP_CODE"
echo "Body: $BODY"
# Extract the first composeId from the JSON response array
COMPOSE_ID=$(echo "$BODY" | python3 -c "
import json,sys
data = json.load(sys.stdin)
items = data[0]['result']['data']['json']['items']
print(items[0]['composeId'] if items else '')
" 2>/dev/null || echo "")
echo "composeId=$COMPOSE_ID" >> $GITHUB_OUTPUT
echo "Found composeId: $COMPOSE_ID"
- name: Delete preview stack
if: steps.find.outputs.composeId != ''
run: |
RESPONSE=$(curl -s -w "\n%{http_code}" \
-X POST "${{ secrets.DOKPLOY_URL }}/api/trpc/compose.delete" \
-H "x-api-key: ${{ secrets.DOKPLOY_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{\"0\":{\"json\":{\"composeId\":\"${{ steps.find.outputs.composeId }}\",\"deleteVolumes\":true}}}")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | head -1)
echo "Status: $HTTP_CODE"
echo "Body: $BODY"
[ "$HTTP_CODE" = "200" ] || { echo "Delete failed"; exit 1; }

63
.gitea/workflows/ios.yaml Normal file
View File

@@ -0,0 +1,63 @@
name: iOS CI
on:
push:
branches: ["v2", "main"]
paths:
- "ios/**"
- "justfile"
- ".gitea/workflows/ios.yaml"
- ".gitea/workflows/ios-release.yaml"
pull_request:
branches: ["v2", "main"]
paths:
- "ios/**"
- "justfile"
- ".gitea/workflows/ios.yaml"
- ".gitea/workflows/ios-release.yaml"
concurrency:
group: ios-macos-runner
cancel-in-progress: true
jobs:
# ── build (simulator) ─────────────────────────────────────────────────────
build:
name: Build
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Install just
run: command -v just || brew install just
- name: Build (simulator)
env:
USER: runner
run: just ios-build
# ── unit tests ────────────────────────────────────────────────────────────
test:
name: Test
runs-on: macos-latest
needs: build
steps:
- uses: actions/checkout@v4
- name: Install just
run: command -v just || brew install just
- name: Run unit tests
env:
USER: runner
run: just ios-test
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: ios/LibNovel/test-results.xml
retention-days: 7

View File

@@ -5,15 +5,18 @@ on:
tags:
- "v*"
defaults:
run:
working-directory: ui
concurrency:
group: ${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
# ── type-check & build ───────────────────────────────────────────────────────
build:
name: Build
runs-on: ubuntu-latest
defaults:
run:
working-directory: ui
steps:
- uses: actions/checkout@v4
@@ -37,33 +40,29 @@ jobs:
name: Docker
runs-on: ubuntu-latest
needs: [build]
defaults:
run:
working-directory: .
steps:
- uses: actions/checkout@v4
- name: Log in to Gitea registry
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
registry: gitea.kalekber.cc
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: gitea.kalekber.cc/kamil/libnovel-ui
images: ${{ secrets.DOCKER_USER }}/libnovel-ui
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: ./ui
context: ui
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -35,6 +35,7 @@ services:
mc mb --ignore-existing local/libnovel-chapters;
mc mb --ignore-existing local/libnovel-audio;
mc mb --ignore-existing local/libnovel-browse;
mc mb --ignore-existing local/libnovel-avatars;
echo 'buckets ready';
"
environment:
@@ -106,6 +107,7 @@ services:
MINIO_BUCKET_CHAPTERS: "${MINIO_BUCKET_CHAPTERS:-libnovel-chapters}"
MINIO_BUCKET_AUDIO: "${MINIO_BUCKET_AUDIO:-libnovel-audio}"
MINIO_BUCKET_BROWSE: "${MINIO_BUCKET_BROWSE:-libnovel-browse}"
MINIO_BUCKET_AVATARS: "${MINIO_BUCKET_AVATARS:-libnovel-avatars}"
# Public endpoint used to sign presigned audio URLs so browsers can reach them.
# Leave empty to use MINIO_ENDPOINT (fine for local dev).
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-}"

14
ios/.gitignore vendored Normal file
View File

@@ -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

10
ios/LibNovel/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# Fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
fastlane/README.md
# Bundler
.bundle
vendor/bundle

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store</string>
<key>teamID</key>
<string>GHZXC6FVMU</string>
<key>uploadBitcode</key>
<false/>
<key>uploadSymbols</key>
<true/>
<key>signingStyle</key>
<string>manual</string>
<key>provisioningProfiles</key>
<dict>
<key>com.kalekber.LibNovel</key>
<string>LibNovel Distribution</string>
</dict>
</dict>
</plist>

3
ios/LibNovel/Gemfile Normal file
View File

@@ -0,0 +1,3 @@
source "https://rubygems.org"
gem "fastlane"

View File

@@ -0,0 +1,697 @@
// !$*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 */; };
41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC6F837FF2E902E334ED72E /* String+App.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 */; };
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 = "<group>"; };
235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LibNovelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
2D5C115992F1CE2326236765 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = "<group>"; };
39DE056C37FBC5EED8771821 /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = "<group>"; };
3AB2E843D93461074A89A171 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
4B820081FA4817765A39939A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
4F56C8E2BC3614530B81569D /* LibNovelApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelApp.swift; sourceTree = "<group>"; };
5A776719B77EDDB5E44743B0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
762E378B9BC2161A7AA2CC36 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
7CAFB96D2500F34F0B0C860C /* NavDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavDestination.swift; sourceTree = "<group>"; };
7CEF6782A2A28B2A485CBD48 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderView.swift; sourceTree = "<group>"; };
837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailViewModel.swift; sourceTree = "<group>"; };
8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderViewModel.swift; sourceTree = "<group>"; };
8E89FD8F46747CA653C5203D /* CommonViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonViews.swift; sourceTree = "<group>"; };
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = "<group>"; };
9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewModel.swift; sourceTree = "<group>"; };
9D83BB88C4306BE7A4F947CB /* Color+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+App.swift"; sourceTree = "<group>"; };
B4C918833E173D6B44D06955 /* LibNovelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelTests.swift; sourceTree = "<group>"; };
B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
C21107BECA55C07416E0CB8B /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
D6268D60803940CBD38FB921 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
DB13E89E50529E3081533A66 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; };
DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViews.swift; sourceTree = "<group>"; };
F219788AE5ACBD6F240674F5 /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = "<group>"; };
FC338B05EA6DB22900712000 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = "<group>"; };
FEC6F837FF2E902E334ED72E /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+App.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
EFE3211B202EDF04EB141EFB /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
2C0FB0EDFF9B3E24B97F4214 /* Resources */ = {
isa = PBXGroup;
children = (
5A776719B77EDDB5E44743B0 /* Assets.xcassets */,
);
path = Resources;
sourceTree = "<group>";
};
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 = "<group>";
};
2F18D1275D6022B9847E310E /* Auth */ = {
isa = PBXGroup;
children = (
7CEF6782A2A28B2A485CBD48 /* AuthView.swift */,
);
path = Auth;
sourceTree = "<group>";
};
3DB66C5703A4CCAFFA1B7AFE /* Profile */ = {
isa = PBXGroup;
children = (
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */,
);
path = Profile;
sourceTree = "<group>";
};
426F7C5465758645B93A1AB1 /* Networking */ = {
isa = PBXGroup;
children = (
B593F179EC3E9112126B540B /* APIClient.swift */,
);
path = Networking;
sourceTree = "<group>";
};
4EAB87A1ED4943A311F26F84 /* ChapterReader */ = {
isa = PBXGroup;
children = (
81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */,
);
path = ChapterReader;
sourceTree = "<group>";
};
5D5809803A3D74FAE19DB218 /* Common */ = {
isa = PBXGroup;
children = (
8E89FD8F46747CA653C5203D /* CommonViews.swift */,
);
path = Common;
sourceTree = "<group>";
};
6318D3C6F0DC6C8E2C377103 /* Products */ = {
isa = PBXGroup;
children = (
1B8BF3DB582A658386E402C7 /* LibNovel.app */,
235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
646952B9CE927F8038FF0A13 /* LibNovelTests */ = {
isa = PBXGroup;
children = (
B4C918833E173D6B44D06955 /* LibNovelTests.swift */,
);
path = LibNovelTests;
sourceTree = "<group>";
};
80148B5E27BD0A3DEDB3ADAA /* Models */ = {
isa = PBXGroup;
children = (
762E378B9BC2161A7AA2CC36 /* Models.swift */,
);
path = Models;
sourceTree = "<group>";
};
811FC0F6B9C209D6EC8543BD /* Home */ = {
isa = PBXGroup;
children = (
D6268D60803940CBD38FB921 /* HomeView.swift */,
);
path = Home;
sourceTree = "<group>";
};
89F2CB14192E7D7565A588E0 /* Player */ = {
isa = PBXGroup;
children = (
DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */,
);
path = Player;
sourceTree = "<group>";
};
8E8AAA58A33084ADB8AEA80C /* Browse */ = {
isa = PBXGroup;
children = (
1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */,
);
path = Browse;
sourceTree = "<group>";
};
9AF55E5D62F980C72431782A = {
isa = PBXGroup;
children = (
A28A184E73B15138A4D13F31 /* LibNovel */,
646952B9CE927F8038FF0A13 /* LibNovelTests */,
6318D3C6F0DC6C8E2C377103 /* Products */,
);
indentWidth = 4;
sourceTree = "<group>";
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 = "<group>";
};
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 = "<group>";
};
DA6F6F625578875F3E74F1D3 /* Services */ = {
isa = PBXGroup;
children = (
DB13E89E50529E3081533A66 /* AudioPlayerService.swift */,
F219788AE5ACBD6F240674F5 /* AuthStore.swift */,
);
path = Services;
sourceTree = "<group>";
};
FA994FD601E79EC811D822A4 /* Library */ = {
isa = PBXGroup;
children = (
C21107BECA55C07416E0CB8B /* LibraryView.swift */,
);
path = Library;
sourceTree = "<group>";
};
FB5C0D4925633786D28C6DE3 /* BookDetail */ = {
isa = PBXGroup;
children = (
39DE056C37FBC5EED8771821 /* BookDetailView.swift */,
);
path = BookDetail;
sourceTree = "<group>";
};
FD5EDEE9747643D45CA6423E /* Extensions */ = {
isa = PBXGroup;
children = (
9D83BB88C4306BE7A4F947CB /* Color+App.swift */,
7CAFB96D2500F34F0B0C860C /* NavDestination.swift */,
FEC6F837FF2E902E334ED72E /* String+App.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
FE92158CC5DA9AD446062724 /* App */ = {
isa = PBXGroup;
children = (
4B820081FA4817765A39939A /* ContentView.swift */,
4F56C8E2BC3614530B81569D /* LibNovelApp.swift */,
2D5C115992F1CE2326236765 /* RootTabView.swift */,
);
path = App;
sourceTree = "<group>";
};
/* 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 */,
);
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 = 2630;
};
buildConfigurationList = D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = 9AF55E5D62F980C72431782A;
minimizedProjectReferenceProxies = 1;
packageReferences = (
AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */,
);
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 */,
41FB51553F1F1AEBFEA91C0A /* String+App.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 = com.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 = com.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 = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = GHZXC6FVMU;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel;
PROVISIONING_PROFILE_SPECIFIER = "";
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 = 1000;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_PREVIEWS = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = 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;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
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 = "Apple Distribution";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = GHZXC6FVMU;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = GHZXC6FVMU;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel;
PROVISIONING_PROFILE = "af592c3a-f60b-4ac1-a14f-30b8a206017f";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LibNovel Distribution";
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 = 1000;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_PREVIEWS = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = 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;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
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;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
09584EAB68A07B47F876A062 /* Kingfisher */ = {
isa = XCSwiftPackageProductDependency;
package = AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */;
productName = Kingfisher;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = A10A669C0C8B43078C0FEE9F /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,15 @@
{
"originHash" : "ad75ae2d3b8d8b80d99635f65213a3c1092464aa54a86354f850b8317b6fa240",
"pins" : [
{
"identity" : "kingfisher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/onevcat/Kingfisher",
"state" : {
"revision" : "c92b84898e34ab46ff0dad86c02a0acbe2d87008",
"version" : "8.8.0"
}
}
],
"version" : 3
}

View File

@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2630"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D039EDECDE3998D8534BB680"
BuildableName = "LibNovel.app"
BlueprintName = "LibNovel"
ReferencedContainer = "container:LibNovel.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D039EDECDE3998D8534BB680"
BuildableName = "LibNovel.app"
BlueprintName = "LibNovel"
ReferencedContainer = "container:LibNovel.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5E6D3E8266BFCF0AAF5EC79D"
BuildableName = "LibNovelTests.xctest"
BlueprintName = "LibNovelTests"
ReferencedContainer = "container:LibNovel.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D039EDECDE3998D8534BB680"
BuildableName = "LibNovel.app"
BlueprintName = "LibNovel"
ReferencedContainer = "container:LibNovel.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "LIBNOVEL_BASE_URL"
value = "[&quot;value&quot;: &quot;https://v2.libnovel.kalekber.cc&quot;, &quot;isEnabled&quot;: true]"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D039EDECDE3998D8534BB680"
BuildableName = "LibNovel.app"
BlueprintName = "LibNovel"
ReferencedContainer = "container:LibNovel.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -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()
}
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -0,0 +1,105 @@
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
/// Live drag offset while the user is dragging the full player down.
@State private var fullPlayerDragOffset: CGFloat = 0
enum Tab: Hashable {
case home, library, browse, profile
}
/// Height of the mini player bar (progress line 2pt + vertical padding 20pt + content ~44pt)
private let miniPlayerBarHeight: CGFloat = AppLayout.miniPlayerBarHeight
var body: some View {
ZStack(alignment: .bottom) {
TabView(selection: $selectedTab) {
HomeView()
.tabItem { Label("Home", systemImage: "house.fill") }
.tag(Tab.home)
LibraryView()
.tabItem { Label("Library", systemImage: "book.pages.fill") }
.tag(Tab.library)
BrowseView()
.tabItem { Label("Discover", systemImage: "sparkles") }
.tag(Tab.browse)
ProfileView()
.tabItem { Label("Profile", systemImage: "gear") }
.tag(Tab.profile)
}
// Reserve space for the mini-player above the tab bar so scroll content
// never slides beneath it.
.safeAreaInset(edge: .bottom) {
if audioPlayer.isActive {
Color.clear.frame(height: miniPlayerBarHeight)
}
}
// Mini-player pinned above the tab bar (hidden while full player is open)
if audioPlayer.isActive && !showFullPlayer {
MiniPlayerView(showFullPlayer: $showFullPlayer)
.padding(.bottom, tabBarHeight)
.transition(.move(edge: .bottom).combined(with: .opacity))
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive)
}
// Full player slides up from the bottom as a custom overlay (not a sheet)
// so it feels physically connected to the mini player bar.
if showFullPlayer {
FullPlayerView(onDismiss: {
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
showFullPlayer = false
fullPlayerDragOffset = 0
}
})
.offset(y: max(fullPlayerDragOffset, 0))
.gesture(
DragGesture(minimumDistance: 10)
.onChanged { value in
if value.translation.height > 0 {
// Rubberband slightly so it doesn't feel locked
fullPlayerDragOffset = value.translation.height
}
}
.onEnded { value in
let velocity = value.predictedEndTranslation.height - value.translation.height
if value.translation.height > 120 || velocity > 400 {
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
showFullPlayer = false
fullPlayerDragOffset = 0
}
} else {
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
fullPlayerDragOffset = 0
}
}
}
)
.transition(.move(edge: .bottom))
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer)
.ignoresSafeArea()
}
}
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer)
}
// Approximate safe-area-aware tab bar height
private var tabBarHeight: CGFloat {
let window = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first?.windows.first(where: \.isKeyWindow)
let bottomInset = window?.safeAreaInsets.bottom ?? 0
return 49 + bottomInset // 49pt is the standard iOS tab bar height
}
}

View File

@@ -0,0 +1,10 @@
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)
}
extension ShapeStyle where Self == Color {
static var amber: Color { .amber }
}

View File

@@ -0,0 +1,100 @@
import SwiftUI
// MARK: - Navigation destination enum used across all tabs
enum NavDestination: Hashable {
case book(String) // slug
case chapter(String, Int) // slug + chapter number
}
// MARK: - View extensions for shared navigation + error alert patterns
extension View {
/// Registers the app-wide navigation destinations for NavDestination values.
/// Apply once per NavigationStack instead of repeating the switch in every tab.
func appNavigationDestination() -> some View {
modifier(AppNavigationDestinationModifier())
}
/// Presents a standard "Error" alert driven by an optional String binding.
/// Dismissing the alert sets the binding back to nil.
func errorAlert(_ error: Binding<String?>) -> some View {
alert("Error", isPresented: Binding(
get: { error.wrappedValue != nil },
set: { if !$0 { error.wrappedValue = nil } }
)) {
Button("OK") { error.wrappedValue = nil }
} message: {
Text(error.wrappedValue ?? "")
}
}
}
// MARK: - Navigation destination modifier
private struct AppNavigationDestinationModifier: ViewModifier {
@Namespace private var zoomNamespace
func body(content: Content) -> some View {
if #available(iOS 18.0, *) {
content
.navigationDestination(for: NavDestination.self) { dest in
switch dest {
case .book(let slug):
BookDetailView(slug: slug)
.navigationTransition(.zoom(sourceID: slug, in: zoomNamespace))
case .chapter(let slug, let n):
ChapterReaderView(slug: slug, chapterNumber: n)
}
}
// Expose namespace to child views via environment
.environment(\.bookZoomNamespace, zoomNamespace)
} else {
content
.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)
}
}
}
}
}
// MARK: - Environment key for zoom namespace
struct BookZoomNamespaceKey: EnvironmentKey {
static var defaultValue: Namespace.ID? { nil }
}
extension EnvironmentValues {
var bookZoomNamespace: Namespace.ID? {
get { self[BookZoomNamespaceKey.self] }
set { self[BookZoomNamespaceKey.self] = newValue }
}
}
// MARK: - Cover card zoom source modifier
/// Apply this to any cover image that should be a zoom source for book navigation.
/// Falls back to a no-op on iOS 17 or when no namespace is available.
struct BookCoverZoomSource: ViewModifier {
let slug: String
@Environment(\.bookZoomNamespace) private var namespace
func body(content: Content) -> some View {
if #available(iOS 18.0, *), let ns = namespace {
content.matchedTransitionSource(id: slug, in: ns)
} else {
content
}
}
}
extension View {
/// Marks a cover image as the zoom source for a book's navigation transition.
func bookCoverZoomSource(slug: String) -> some View {
modifier(BookCoverZoomSource(slug: slug))
}
}

View File

@@ -0,0 +1,49 @@
import Foundation
// MARK: - String helpers for display purposes
extension String {
/// Strips trailing relative-date suffixes (e.g. "2 years ago", "3 days ago",
/// or "(One)4 years ago" where the number is attached without a preceding space).
func strippingTrailingDate() -> String {
let units = ["second", "minute", "hour", "day", "week", "month", "year"]
let lower = self.lowercased()
for unit in units {
for suffix in [unit + "s ago", unit + " ago"] {
guard let suffixRange = lower.range(of: suffix, options: .backwards) else { continue }
// Everything before the suffix
let before = String(self[self.startIndex ..< suffixRange.lowerBound])
let trimmed = before.trimmingCharacters(in: .whitespaces)
// Strip trailing digits (the numeric count, which may be attached without a space)
var result = trimmed
while let last = result.last, last.isNumber {
result.removeLast()
}
result = result.trimmingCharacters(in: .whitespaces)
if result != trimmed {
// We actually stripped some digits return cleaned result
return result
}
// Fallback: number preceded by space
if let spaceIdx = trimmed.lastIndex(of: " ") {
let potentialNum = String(trimmed[trimmed.index(after: spaceIdx)...])
if Int(potentialNum) != nil {
return String(trimmed[trimmed.startIndex ..< spaceIdx])
.trimmingCharacters(in: .whitespaces)
}
} else if Int(trimmed) != nil {
return ""
}
}
}
return self
}
}
// MARK: - App-wide layout constants
enum AppLayout {
/// Height of the persistent mini-player bar:
/// 12pt vertical padding (top) + 56pt cover height + 12pt vertical padding (bottom) + 12pt horizontal margin.
static let miniPlayerBarHeight: CGFloat = 92
}

View File

@@ -0,0 +1,261 @@
import Foundation
import SwiftUI
// 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: - User Settings
struct UserSettings: Codable {
var id: String?
var autoNext: Bool
var voice: String
var speed: Double
// Server sends/expects camelCase: { autoNext, voice, speed }
// (No CodingKeys needed Swift synthesises the same names by default)
static let `default` = UserSettings(id: nil, autoNext: false, voice: "af_bella", speed: 1.0)
}
// MARK: - Reading Display Settings (local only stored in UserDefaults)
enum ReaderTheme: String, CaseIterable, Codable {
case white, sepia, night
var backgroundColor: Color {
switch self {
case .white: return Color(.sRGB, white: 1.0, opacity: 1)
case .sepia: return Color(red: 0.97, green: 0.93, blue: 0.82)
case .night: return Color(red: 0.10, green: 0.10, blue: 0.12)
}
}
var textColor: Color {
switch self {
case .white: return Color(.sRGB, white: 0.1, opacity: 1)
case .sepia: return Color(red: 0.25, green: 0.18, blue: 0.08)
case .night: return Color(red: 0.85, green: 0.85, blue: 0.87)
}
}
var colorScheme: ColorScheme? {
switch self {
case .white: return nil // follows system
case .sepia: return .light
case .night: return .dark
}
}
}
enum ReaderFont: String, CaseIterable, Codable {
case system = "System"
case georgia = "Georgia"
case newYork = "New York"
var fontName: String? {
switch self {
case .system: return nil
case .georgia: return "Georgia"
case .newYork: return "NewYorkMedium-Regular"
}
}
}
struct ReaderSettings: Codable, Equatable {
var fontSize: CGFloat
var lineSpacing: CGFloat
var font: ReaderFont
var theme: ReaderTheme
var scrollMode: Bool
static let `default` = ReaderSettings(
fontSize: 17,
lineSpacing: 1.7,
font: .system,
theme: .white,
scrollMode: false
)
static let userDefaultsKey = "readerSettings"
static func load() -> ReaderSettings {
guard let data = UserDefaults.standard.data(forKey: userDefaultsKey),
let decoded = try? JSONDecoder().decode(ReaderSettings.self, from: data)
else { return .default }
return decoded
}
func save() {
if let data = try? JSONEncoder().encode(self) {
UserDefaults.standard.set(data, forKey: ReaderSettings.userDefaultsKey)
}
}
}
// MARK: - User
struct AppUser: Codable, Identifiable {
let id: String
let username: String
let role: String
let created: String
let avatarURL: String?
var isAdmin: Bool { role == "admin" }
enum CodingKeys: String, CodingKey {
case id, username, role, created
case avatarURL = "avatar_url"
}
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) ?? ""
avatarURL = try c.decodeIfPresent(String.self, forKey: .avatarURL)
}
}
// 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 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"
}
}
struct PreviewChapter: Codable, Identifiable {
var id: Int { number }
let number: Int
let title: String
let url: String
}
struct BookBrief: Codable {
let slug: String
let title: String
let cover: String
}
// MARK: - Audio
enum NextPrefetchStatus {
case none, prefetching, prefetched, failed
}

View File

@@ -0,0 +1,493 @@
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()
var baseURL: URL
private var authCookie: String? // raw "libnovel_auth=<token>" header value
// 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) }
}
}
// MARK: - Low-level request builder
private func makeRequest(_ path: String, method: String = "GET", body: Encodable? = nil) throws -> URLRequest {
// Build URL by appending the path string directly to the base URL string.
// appendingPathComponent() percent-encodes slashes, which breaks multi-segment
// paths like /api/chapter/slug/1. URL(string:) preserves slashes correctly.
let urlString = baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard let url = URL(string: urlString) else {
throw APIError.invalidResponse
}
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<T: Decodable>(_ 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
}
let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8 data, \(data.count) bytes>"
guard (200..<300).contains(http.statusCode) else {
throw APIError.httpError(http.statusCode, rawBody)
}
do {
return try JSONDecoder.iso8601.decode(T.self, from: data)
} catch {
throw APIError.decodingError(error)
}
}
// 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 _: EmptyResponse = try await fetch("/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
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 -> AudioTriggerResponse {
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))
}
/// Poll GET /api/audio/status/{slug}/{n}?voice=... until the job is done or failed.
/// Returns the presigned/proxy URL on success, throws on failure or cancellation.
func pollAudioStatus(slug: String, chapter: Int, voice: String) async throws -> String {
let path = "/api/audio/status/\(slug)/\(chapter)?voice=\(voice)"
struct StatusResponse: Decodable {
let status: String
let url: String?
let error: String?
}
while true {
try Task.checkCancellation()
let r: StatusResponse = try await fetch(path)
switch r.status {
case "done":
guard let url = r.url, !url.isEmpty else {
throw URLError(.badServerResponse)
}
return url
case "failed":
throw NSError(
domain: "AudioGeneration",
code: 0,
userInfo: [NSLocalizedDescriptionKey: r.error ?? "Audio generation failed"]
)
default:
// pending / generating / idle keep polling
try await Task.sleep(nanoseconds: 2_000_000_000) // 2 s
}
}
}
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] {
struct Response: Decodable { let sessions: [UserSession] }
let r: Response = try await fetch("/api/sessions")
return r.sessions
}
func revokeSession(id: String) async throws {
let _: EmptyResponse = try await fetch("/api/sessions/\(id)", method: "DELETE")
}
// MARK: - Avatar
struct AvatarPresignResponse: Decodable {
let uploadURL: String
let key: String
enum CodingKeys: String, CodingKey { case uploadURL = "upload_url"; case key }
}
struct AvatarResponse: Decodable {
let avatarURL: String?
enum CodingKeys: String, CodingKey { case avatarURL = "avatar_url" }
}
/// Upload a profile avatar using a two-step presigned PUT flow:
/// 1. POST /api/profile/avatar get a presigned PUT URL + object key
/// 2. PUT image bytes directly to MinIO via the presigned URL
/// 3. PATCH /api/profile/avatar with the key to record it in PocketBase
/// Returns the presigned GET URL for the uploaded avatar.
func uploadAvatar(_ imageData: Data, mimeType: String = "image/jpeg") async throws -> String? {
// Step 1: request a presigned PUT URL from the SvelteKit server
let presign: AvatarPresignResponse = try await fetch(
"/api/profile/avatar",
method: "POST",
body: ["mime_type": mimeType]
)
// Step 2: PUT the image bytes directly to MinIO
guard let putURL = URL(string: presign.uploadURL) else { throw APIError.invalidResponse }
var putReq = URLRequest(url: putURL)
putReq.httpMethod = "PUT"
putReq.setValue(mimeType, forHTTPHeaderField: "Content-Type")
putReq.httpBody = imageData
let (_, putResp) = try await session.data(for: putReq)
guard let putHttp = putResp as? HTTPURLResponse,
(200..<300).contains(putHttp.statusCode) else {
let code = (putResp as? HTTPURLResponse)?.statusCode ?? 0
throw APIError.httpError(code, "MinIO PUT failed")
}
// Step 3: record the key in PocketBase and get back a presigned GET URL
let result: AvatarResponse = try await fetch(
"/api/profile/avatar",
method: "PATCH",
body: ["key": presign.key]
)
return result.avatarURL
}
}
// 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
}
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"
}
}
/// Returned by POST /api/audio/{slug}/{n}.
/// - 202 Accepted: job enqueued poll via pollAudioStatus()
/// - 200 OK: audio already cached url is ready to play
struct AudioTriggerResponse: Decodable {
let jobId: String? // present on 202
let status: String? // present on 202: "pending" | "generating"
let url: String? // present on 200: proxy URL ready to play
let filename: String? // present on 200
enum CodingKeys: String, CodingKey {
case jobId = "job_id"
case status, url, filename
}
/// True when the server accepted the request and created an async job.
var isAsync: Bool { jobId != nil }
}
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
}()
}

View File

@@ -0,0 +1,12 @@
{
"colors": [
{
"color": {
"color-space": "srgb",
"components": { "alpha": "1.000", "blue": "0.040", "green": "0.620", "red": "0.960" }
},
"idiom": "universal"
}
],
"info": { "author": "xcode", "version": 1 }
}

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@@ -0,0 +1,3 @@
{
"info": { "author": "xcode", "version": 1 }
}

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>LibNovel</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleName</key>
<string>LibNovel</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>1000</string>
<key>LIBNOVEL_BASE_URL</key>
<string>$(LIBNOVEL_BASE_URL)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,616 @@
import Foundation
import AVFoundation
import MediaPlayer
import Combine
import Kingfisher
// MARK: - PlaybackProgress
// Isolated ObservableObject for high-frequency playback state (currentTime,
// duration, isPlaying). Keeping these separate from AudioPlayerService means
// the 0.5-second time-observer ticks only invalidate views that explicitly
// observe PlaybackProgress menus and other stable UI are unaffected.
@MainActor
final class PlaybackProgress: ObservableObject {
@Published var currentTime: Double = 0
@Published var duration: Double = 0
@Published var isPlaying: Bool = false
}
// MARK: - AudioPlayerService
// Central singleton that owns AVPlayer, drives audio state, handles lock-screen
// controls (NowPlayingInfoCenter + MPRemoteCommandCenter), and pre-fetches the
// next chapter audio.
@MainActor
final class AudioPlayerService: ObservableObject {
// MARK: - Published state
@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
/// High-frequency playback state (currentTime / duration / isPlaying).
/// Views that only need the seek bar or play-pause button should observe
/// this directly so they don't trigger re-renders of menu-bearing parents.
let progress = PlaybackProgress()
// Convenience forwarders so non-view call sites keep compiling unchanged.
var currentTime: Double {
get { progress.currentTime }
set { progress.currentTime = newValue }
}
var duration: Double {
get { progress.duration }
set { progress.duration = newValue }
}
var isPlaying: Bool {
get { progress.isPlaying }
set { progress.isPlaying = newValue }
}
@Published var autoNext: Bool = false
@Published var nextChapter: Int? = nil
@Published var prevChapter: Int? = nil
@Published var sleepTimer: SleepTimerOption? = nil
/// Human-readable countdown string shown in the full player near the moon button.
/// e.g. "38:12" for minute-based, "2 ch left" for chapter-based, "" when off.
@Published var sleepTimerRemainingText: String = ""
@Published var nextPrefetchStatus: NextPrefetchStatus = .none
@Published var nextAudioURL: String = ""
@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 durationObserver: AnyCancellable?
private var finishObserver: AnyCancellable?
private var generationTask: Task<Void, Never>?
private var prefetchTask: Task<Void, Never>?
// Cached cover image downloaded once per chapter load, reused on every
// updateNowPlaying() call so we don't re-download on every play/pause/seek.
private var cachedCoverArtwork: MPMediaItemArtwork?
private var cachedCoverURL: String = ""
// Sleep timer tracking
private var sleepTimerTask: Task<Void, Never>?
private var sleepTimerStartChapter: Int = 0
/// Absolute deadline for minute-based timers (nil when not active or chapter-based).
private var sleepTimerDeadline: Date? = nil
/// 1-second tick task that keeps sleepTimerRemainingText up-to-date.
private var sleepTimerCountdownTask: Task<Void, Never>? = nil
// 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?, prevChapter: Int?) {
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.prevChapter = prevChapter
self.nextPrefetchStatus = .none
self.nextAudioURL = ""
self.nextPrefetchedChapter = nil
// Reset sleep timer start chapter if it's a chapter-based timer
if case .chapters = sleepTimer {
sleepTimerStartChapter = chapter
}
status = .generating
generationProgress = 0
// Invalidate cover cache if the book changed.
if coverURL != cachedCoverURL {
cachedCoverArtwork = nil
cachedCoverURL = coverURL
prefetchCoverArtwork(from: coverURL)
}
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)
currentTime = seconds // optimistic UI update
player?.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
guard let self else { return }
Task { @MainActor in self.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 setSleepTimer(_ option: SleepTimerOption?) {
// Cancel existing timer + countdown
sleepTimerTask?.cancel()
sleepTimerTask = nil
sleepTimerCountdownTask?.cancel()
sleepTimerCountdownTask = nil
sleepTimerDeadline = nil
sleepTimer = option
guard let option else {
sleepTimerRemainingText = ""
return
}
// Start timer based on option
switch option {
case .chapters(let count):
sleepTimerStartChapter = chapter
// Update display immediately; chapter changes are tracked in handlePlaybackFinished.
updateChapterTimerLabel(chaptersRemaining: count)
case .minutes(let minutes):
let deadline = Date().addingTimeInterval(Double(minutes) * 60)
sleepTimerDeadline = deadline
// Stop playback when the deadline is reached.
sleepTimerTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(minutes) * 60 * 1_000_000_000)
guard let self, !Task.isCancelled else { return }
await MainActor.run {
self.stop()
self.sleepTimer = nil
self.sleepTimerRemainingText = ""
}
}
// 1-second tick to keep the countdown label fresh.
sleepTimerCountdownTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 1_000_000_000)
guard let self, !Task.isCancelled else { return }
await MainActor.run {
guard let deadline = self.sleepTimerDeadline else { return }
let remaining = max(0, deadline.timeIntervalSinceNow)
self.sleepTimerRemainingText = Self.formatCountdown(remaining)
}
}
}
// Set initial label without waiting for the first tick.
sleepTimerRemainingText = Self.formatCountdown(Double(minutes) * 60)
}
}
private func updateChapterTimerLabel(chaptersRemaining: Int) {
sleepTimerRemainingText = chaptersRemaining == 1 ? "1 ch left" : "\(chaptersRemaining) ch left"
}
private static func formatCountdown(_ seconds: Double) -> String {
let s = Int(max(0, seconds))
let m = s / 60
let sec = s % 60
return "\(m):\(String(format: "%02d", sec))"
}
func stop() {
player?.pause()
teardownPlayer()
isPlaying = false
currentTime = 0
duration = 0
audioURL = ""
status = .idle
// Cancel sleep timer + countdown
sleepTimerTask?.cancel()
sleepTimerTask = nil
sleepTimerCountdownTask?.cancel()
sleepTimerCountdownTask = nil
sleepTimerDeadline = nil
sleepTimer = nil
sleepTimerRemainingText = ""
}
// MARK: - Audio generation
private func generateAudio() async {
guard !slug.isEmpty, chapter > 0 else { return }
do {
// Fast path: audio already in MinIO get a presigned URL and play immediately.
if let presignedURL = try? await APIClient.shared.presignAudio(slug: slug, chapter: chapter, voice: voice) {
audioURL = presignedURL
status = .ready
generationProgress = 100
await playURL(presignedURL)
await prefetchNext()
return
}
// Slow path: trigger TTS generation (async returns 202 immediately).
status = .generating
generationProgress = 10
let trigger = try await APIClient.shared.triggerAudio(slug: slug, chapter: chapter, voice: voice, speed: speed)
let playableURL: String
if trigger.isAsync {
// 202 Accepted: poll until done.
generationProgress = 30
playableURL = try await APIClient.shared.pollAudioStatus(slug: slug, chapter: chapter, voice: voice)
} else {
// 200: already cached URL returned inline.
guard let url = trigger.url, !url.isEmpty else {
throw URLError(.badServerResponse)
}
playableURL = url
}
audioURL = playableURL
status = .ready
generationProgress = 100
await playURL(playableURL)
await prefetchNext()
} catch is CancellationError {
// Cancelled no-op
} catch {
status = .error(error.localizedDescription)
errorMessage = error.localizedDescription
}
}
// MARK: - Prefetch next chapter
// Always prefetch regardless of autoNext faster playback when the user
// manually navigates forward. autoNext only controls whether we auto-navigate.
private func prefetchNext() async {
guard let next = nextChapter, !Task.isCancelled else { return }
nextPrefetchStatus = .prefetching
nextPrefetchedChapter = next
do {
// Fast path: already in MinIO.
if let presignedURL = try? await APIClient.shared.presignAudio(slug: slug, chapter: next, voice: voice) {
nextAudioURL = presignedURL
nextPrefetchStatus = .prefetched
return
}
// Slow path: trigger generation; poll until done (background won't block playback).
let trigger = try await APIClient.shared.triggerAudio(slug: slug, chapter: next, voice: voice, speed: speed)
let url: String
if trigger.isAsync {
url = try await APIClient.shared.pollAudioStatus(slug: slug, chapter: next, voice: voice)
} else {
guard let u = trigger.url, !u.isEmpty else { throw URLError(.badServerResponse) }
url = u
}
nextAudioURL = url
nextPrefetchStatus = .prefetched
} catch {
nextPrefetchStatus = .failed
}
}
// MARK: - AVPlayer management
private func playURL(_ urlString: String) async {
// Resolve relative paths (e.g. "/api/audio/...") to absolute URLs.
let resolved: URL?
if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") {
resolved = URL(string: urlString)
} else {
resolved = URL(string: urlString, relativeTo: await APIClient.shared.baseURL)?.absoluteURL
}
guard let url = resolved else { return }
teardownPlayer()
let item = AVPlayerItem(url: url)
playerItem = item
player = AVPlayer(playerItem: item)
// KVO: update duration as soon as asset metadata is loaded.
durationObserver = item.publisher(for: \.duration)
.receive(on: RunLoop.main)
.sink { [weak self] dur in
guard let self else { return }
let secs = dur.seconds
if secs.isFinite && secs > 0 {
self.duration = secs
self.updateNowPlaying()
}
}
// KVO: set playback rate once the item is ready.
// Do NOT call player?.play() unconditionally let readyToPlay trigger it
// so we don't race between AVPlayer's internal buffering and our call.
statusObserver = item.publisher(for: \.status)
.receive(on: RunLoop.main)
.sink { [weak self] itemStatus in
guard let self else { return }
if itemStatus == .readyToPlay {
self.player?.rate = Float(self.speed)
self.isPlaying = true
self.updateNowPlaying()
} else if itemStatus == .failed {
self.status = .error(item.error?.localizedDescription ?? "Playback failed")
self.errorMessage = item.error?.localizedDescription ?? "Playback failed"
}
}
// Periodic time observer for seek bar position.
timeObserver = player?.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
queue: .main
) { [weak self] time in
guard let self else { return }
Task { @MainActor in
let secs = time.seconds
if secs.isFinite && secs >= 0 {
self.currentTime = secs
}
}
}
// Observe when playback ends.
finishObserver = NotificationCenter.default
.publisher(for: AVPlayerItem.didPlayToEndTimeNotification, object: item)
.sink { [weak self] _ in
Task { @MainActor in
self?.handlePlaybackFinished()
}
}
// Kick off buffering actual playback starts via statusObserver above.
player?.play()
}
private func teardownPlayer() {
if let observer = timeObserver { player?.removeTimeObserver(observer) }
timeObserver = nil
statusObserver = nil
durationObserver = nil
finishObserver = nil
player = nil
playerItem = nil
}
private func handlePlaybackFinished() {
isPlaying = false
guard let next = nextChapter else { return }
// Check chapter-based sleep timer
if case .chapters(let count) = sleepTimer {
let chaptersPlayed = chapter - sleepTimerStartChapter + 1
if chaptersPlayed >= count {
stop()
return
}
// Update the remaining chapters label.
let remaining = count - chaptersPlayed
updateChapterTimerLabel(chaptersRemaining: remaining)
}
// Always notify the view that the chapter finished (it may update UI).
NotificationCenter.default.post(
name: .audioDidFinishChapter,
object: nil,
userInfo: ["next": next, "autoNext": autoNext]
)
// If autoNext is on, load the next chapter internally right away.
// We already have the metadata in `chapters`, so we can reconstruct
// everything without waiting for the view to navigate.
guard autoNext else { return }
let nextTitle = chapters.first(where: { $0.number == next })?.title ?? ""
let nextNextChapter = chapters.first(where: { $0.number > next })?.number
let nextPrevChapter: Int? = chapter // Current chapter becomes previous for the next one
// If we already prefetched a URL for the next chapter, skip straight to
// playback and kick off generation in the background for the one after.
if nextPrefetchStatus == .prefetched, !nextAudioURL.isEmpty {
let url = nextAudioURL
// Advance state before tearing down the current player.
chapter = next
chapterTitle = nextTitle
nextChapter = nextNextChapter
prevChapter = nextPrevChapter
nextPrefetchStatus = .none
nextAudioURL = ""
nextPrefetchedChapter = nil
audioURL = url
status = .ready
generationProgress = 100
// Update sleep timer start chapter if using chapter-based timer
if case .chapters = sleepTimer {
sleepTimerStartChapter = next
}
generationTask = Task {
await playURL(url)
await prefetchNext()
}
} else {
// No prefetch available do a full load.
load(
slug: slug,
chapter: next,
chapterTitle: nextTitle,
bookTitle: bookTitle,
coverURL: coverURL,
voice: voice,
speed: speed,
chapters: chapters,
nextChapter: nextNextChapter,
prevChapter: nextPrevChapter
)
}
}
// MARK: - Cover art prefetch
private func prefetchCoverArtwork(from urlString: String) {
guard !urlString.isEmpty, let url = URL(string: urlString) else { return }
KingfisherManager.shared.retrieveImage(with: url) { [weak self] result in
guard let self else { return }
if case .success(let value) = result {
let image = value.image
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
Task { @MainActor in
self.cachedCoverArtwork = artwork
self.updateNowPlaying()
}
}
}
}
// 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
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 = [15]
center.skipForwardCommand.addTarget { [weak self] _ in
self?.skip(by: 15)
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
]
// Use cached artwork downloaded once in prefetchCoverArtwork().
if let artwork = cachedCoverArtwork {
info[MPMediaItemPropertyArtwork] = artwork
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
}
// MARK: - Supporting types
enum AudioPlayerStatus: Equatable {
case idle
case generating // covers both "loading" and "generating TTS" phases
case ready
case error(String)
static func == (lhs: AudioPlayerStatus, rhs: AudioPlayerStatus) -> Bool {
switch (lhs, rhs) {
case (.idle, .idle), (.generating, .generating), (.ready, .ready):
return true
case (.error(let a), .error(let b)):
return a == b
default:
return false
}
}
}
enum SleepTimerOption: Equatable {
case chapters(Int) // Stop after N chapters
case minutes(Int) // Stop after N minutes
}
extension Notification.Name {
static let audioDidFinishChapter = Notification.Name("audioDidFinishChapter")
static let skipToNextChapter = Notification.Name("skipToNextChapter")
static let skipToPrevChapter = Notification.Name("skipToPrevChapter")
}

View File

@@ -0,0 +1,146 @@
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
/// Re-validates the current session and refreshes `user` + `settings`.
/// Call this after any operation that may change the user record (e.g. avatar upload).
func validateToken() async {
guard let token = loadToken() else { return }
await validateToken(token)
}
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)
}
}

View File

@@ -0,0 +1,47 @@
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 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 {
if !(error is CancellationError) {
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
}
}
}

View File

@@ -0,0 +1,73 @@
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 {
if !(error is CancellationError) {
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 {
if !(error is CancellationError) {
self.error = error.localizedDescription
}
}
isLoading = false
}
}

View File

@@ -0,0 +1,69 @@
import Foundation
@MainActor
final class ChapterReaderViewModel: ObservableObject {
let slug: String
private(set) var chapter: Int
@Published var content: ChapterResponse?
@Published var isLoading = false
@Published var error: String?
init(slug: String, chapter: Int) {
self.slug = slug
self.chapter = chapter
}
/// Switch to a different chapter in-place: resets state and updates `chapter`
/// so that `.task(id: currentChapter)` in the View re-fires `load()`.
func switchChapter(to newChapter: Int) {
guard newChapter != chapter else { return }
chapter = newChapter
content = nil
error = nil
}
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 {
if !(error is CancellationError) {
self.error = error.localizedDescription
}
}
isLoading = false
}
func toggleAudio(audioPlayer: AudioPlayerService, settings: UserSettings) {
guard let content else { return }
// Only treat as "current" if the player is active (not idle/stopped).
// If the user stopped playback, isActive is false we must re-load.
let isCurrent = audioPlayer.isActive &&
audioPlayer.slug == slug &&
audioPlayer.chapter == chapter
if isCurrent {
audioPlayer.togglePlayPause()
} else {
let nextChapter: Int? = content.next
let prevChapter: Int? = content.prev
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,
prevChapter: prevChapter
)
}
}
}

View File

@@ -0,0 +1,28 @@
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 {
if !(error is CancellationError) {
self.error = error.localizedDescription
}
}
isLoading = false
}
}

View File

@@ -0,0 +1,21 @@
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 {
if !(error is CancellationError) {
self.error = error.localizedDescription
}
}
isLoading = false
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -0,0 +1,388 @@
import SwiftUI
import Kingfisher
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 = 50
init(slug: String) {
self.slug = slug
_vm = StateObject(wrappedValue: BookDetailViewModel(slug: slug))
}
var body: some View {
ZStack(alignment: .top) {
// Scroll content
ScrollView {
VStack(alignment: .leading, spacing: 0) {
if vm.isLoading {
ProgressView().frame(maxWidth: .infinity).padding(.top, 120)
} else if let book = vm.book {
heroSection(book: book)
metaSection(book: book)
Divider().padding(.horizontal)
chapterSection(book: book)
}
}
}
.ignoresSafeArea(edges: .top)
}
.navigationBarTitleDisplayMode(.inline)
.toolbar { bookmarkButton }
.task { await vm.load() }
.errorAlert($vm.error)
}
// MARK: - Hero
@ViewBuilder
private func heroSection(book: Book) -> some View {
ZStack(alignment: .bottom) {
// Full-bleed blurred background
KFImage(URL(string: book.cover))
.resizable()
.scaledToFill()
.frame(maxWidth: .infinity)
.frame(height: 320)
.blur(radius: 24)
.clipped()
.overlay(
LinearGradient(
colors: [.black.opacity(0.15), .black.opacity(0.68)],
startPoint: .top,
endPoint: .bottom
)
)
// Cover + info column centered
VStack(spacing: 16) {
// Isolated cover with 3D-style shadow
KFImage(URL(string: book.cover))
.resizable()
.placeholder {
RoundedRectangle(cornerRadius: 12)
.fill(Color(.systemGray5))
}
.scaledToFill()
.frame(width: 130, height: 188)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.55), radius: 18, x: 0, y: 10)
.shadow(color: .black.opacity(0.3), radius: 6, x: 0, y: 3)
// Title + author
VStack(spacing: 6) {
Text(book.title)
.font(.title3.bold())
.foregroundStyle(.white)
.multilineTextAlignment(.center)
.lineLimit(3)
.padding(.horizontal, 32)
Text(book.author)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.75))
}
// Genre tags
if !book.genres.isEmpty {
HStack(spacing: 8) {
ForEach(book.genres.prefix(3), id: \.self) { genre in
TagChip(label: genre).colorScheme(.dark)
}
}
}
// Status badge
if !book.status.isEmpty {
StatusBadge(status: book.status)
}
}
.padding(.horizontal)
.padding(.bottom, 28)
}
.frame(minHeight: 320)
}
// MARK: - Meta section (summary + CTAs)
@ViewBuilder
private func metaSection(book: Book) -> some View {
VStack(alignment: .leading, spacing: 0) {
// Quick stats row
HStack(spacing: 0) {
MetaStat(value: "\(book.totalChapters)", label: "Chapters",
icon: "doc.text")
Divider().frame(height: 36)
MetaStat(value: book.status.capitalized.isEmpty ? "" : book.status.capitalized,
label: "Status", icon: "flag")
if book.ranking > 0 {
Divider().frame(height: 36)
MetaStat(value: "#\(book.ranking)", label: "Rank",
icon: "chart.bar.fill")
}
}
.padding(.vertical, 16)
.frame(maxWidth: .infinity)
Divider().padding(.horizontal)
// Summary
VStack(alignment: .leading, spacing: 8) {
Text("About")
.font(.headline)
Text(book.summary)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(summaryExpanded ? nil : 4)
.animation(.easeInOut(duration: 0.2), value: summaryExpanded)
if book.summary.count > 200 {
Button(summaryExpanded ? "Less" : "More") {
withAnimation { summaryExpanded.toggle() }
}
.font(.caption.bold())
.foregroundStyle(.amber)
}
}
.padding(.horizontal)
.padding(.vertical, 16)
Divider().padding(.horizontal)
// 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)
.fontWeight(.semibold)
}
.buttonStyle(.borderedProminent)
.tint(.amber)
NavigationLink(value: NavDestination.chapter(slug, 1)) {
Label("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)
.fontWeight(.semibold)
}
.buttonStyle(.borderedProminent)
.tint(.amber)
}
}
.padding(.horizontal)
.padding(.vertical, 16)
}
}
// 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..<end])
VStack(alignment: .leading, spacing: 0) {
// Section header
HStack {
Text("Chapters")
.font(.headline)
Spacer()
if total > 0 {
Text("\(start + 1)\(end) of \(total)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
.padding(.vertical, 14)
if vm.isLoading {
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,
totalChapters: total)
}
.buttonStyle(.plain)
Divider().padding(.leading)
}
}
// Pagination bar
if total > pageSize {
HStack {
Button {
withAnimation { chapterPage -= 1 }
} label: {
Image(systemName: "chevron.left")
Text("Previous")
}
.disabled(chapterPage == 0)
Spacer()
Text("Page \(chapterPage + 1) of \((total + pageSize - 1) / pageSize)")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button {
withAnimation { chapterPage += 1 }
} label: {
Text("Next")
Image(systemName: "chevron.right")
}
.disabled(end >= total)
}
.font(.subheadline)
.foregroundStyle(.amber)
.padding()
}
Color.clear.frame(height: 32)
}
}
// MARK: - Bookmark toolbar
@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)
}
}
}
}
// MARK: - Chapter row
private struct ChapterRow: View {
let chapter: ChapterIndex
let isCurrent: Bool
let totalChapters: Int
private var progressFraction: Double {
guard totalChapters > 1 else { return 0 }
return Double(chapter.number) / Double(totalChapters)
}
var body: some View {
HStack(spacing: 10) {
// Number badge
ZStack {
Circle()
.fill(isCurrent ? Color.amber : Color(.systemGray6))
Text("\(chapter.number)")
.font(.caption2.bold().monospacedDigit())
.foregroundStyle(isCurrent ? .black : .secondary)
}
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 2) {
let displayTitle: String = {
let stripped = chapter.title.strippingTrailingDate()
if stripped.isEmpty || stripped == "Chapter \(chapter.number)" {
return "Chapter \(chapter.number)"
}
return stripped
}()
Text(displayTitle)
.font(.subheadline)
.fontWeight(isCurrent ? .semibold : .regular)
.foregroundStyle(isCurrent ? .amber : .primary)
.lineLimit(1)
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 2) {
if !chapter.dateLabel.isEmpty {
Text(chapter.dateLabel)
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.contentShape(Rectangle())
}
}
// MARK: - Supporting components
private struct MetaStat: View {
let value: String
let label: String
let icon: String
var body: some View {
VStack(spacing: 4) {
Image(systemName: icon)
.font(.caption)
.foregroundStyle(.amber)
Text(value)
.font(.subheadline.bold())
.lineLimit(1)
.minimumScaleFactor(0.7)
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
}
private struct StatusBadge: View {
let status: String
private var color: Color {
switch status.lowercased() {
case "ongoing", "active": return .green
case "completed": return .blue
case "hiatus": return .orange
default: return .secondary
}
}
var body: some View {
HStack(spacing: 4) {
Circle()
.fill(color)
.frame(width: 6, height: 6)
Text(status.capitalized)
.font(.caption.weight(.medium))
.foregroundStyle(color)
}
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(color.opacity(0.12), in: Capsule())
}
}

View File

@@ -0,0 +1,211 @@
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 {
VStack(spacing: 16) {
if let errMsg = vm.error {
Image(systemName: "wifi.slash")
.font(.largeTitle)
.foregroundStyle(.secondary)
Text(errMsg)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.padding(.horizontal)
Button("Retry") { Task { await vm.loadFirstPage() } }
.buttonStyle(.borderedProminent)
.tint(.amber)
} else {
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)
.bookCoverZoomSource(slug: novel.slug)
}
.buttonStyle(.plain)
}
// Infinite scroll trigger
if vm.hasNext {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
.onAppear { Task { await vm.loadNextPage() } }
}
}
.padding()
}
.refreshable { await vm.loadFirstPage() }
}
}
.navigationTitle("Discover")
.appNavigationDestination()
.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])
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,143 @@
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
/// When true the placeholder is a plain colour fill used for blurred hero backgrounds
/// so the rounded-rect loading indicator doesn't bleed through.
var isBackground: Bool = false
var body: some View {
KFImage(URL(string: url))
.resizable()
.placeholder {
if isBackground {
Color(.systemGray6)
} else {
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())
}
}
// MARK: - Unified chip button (filter/sort chips across all screens)
//
// .filled amber background when selected (genre filter chips in Library)
// .outlined amber border + tint when selected, grey background (sort chips, browse filter chips)
enum ChipButtonStyle { case filled, outlined }
struct ChipButton: View {
let label: String
let isSelected: Bool
var style: ChipButtonStyle = .filled
let action: () -> Void
var body: some View {
Button(action: action) {
Text(label)
.font(chipFont)
.padding(.horizontal, chipHPad)
.padding(.vertical, 6)
.background(background)
.foregroundStyle(foregroundColor)
.overlay(border)
}
.buttonStyle(.plain)
}
private var chipFont: Font {
switch style {
case .filled: return .caption.weight(isSelected ? .semibold : .regular)
case .outlined: return .subheadline.weight(isSelected ? .semibold : .regular)
}
}
private var chipHPad: CGFloat { style == .outlined ? 14 : 12 }
@ViewBuilder
private var background: some View {
switch style {
case .filled:
Capsule().fill(isSelected ? Color.amber : Color(.systemGray5))
case .outlined:
Capsule()
.fill(isSelected ? Color.amber.opacity(0.15) : Color(.systemGray6))
.overlay(Capsule().stroke(isSelected ? Color.amber : .clear, lineWidth: 1.5))
}
}
private var foregroundColor: Color {
switch style {
case .filled: return isSelected ? .white : .primary
case .outlined: return isSelected ? .amber : .primary
}
}
@ViewBuilder
private var border: some View {
// outlined style already has its border baked into `background`
EmptyView()
}
}

View File

@@ -0,0 +1,326 @@
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: 0) {
// Large hero continue card (most recent in-progress book)
if let hero = vm.continueReading.first {
HeroContinueCard(item: hero)
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 28)
}
// Continue reading shelf (remaining items after the hero)
let shelf = vm.continueReading.dropFirst()
if !shelf.isEmpty {
ShelfHeader(title: "Continue Reading")
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 14) {
ForEach(Array(shelf)) { item in
NavigationLink(value: NavDestination.book(item.book.slug)) {
ContinueReadingCard(item: item)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
.padding(.bottom, 4)
}
.padding(.bottom, 28)
}
// Stats strip
if let stats = vm.stats {
StatsStrip(stats: stats)
.padding(.horizontal)
.padding(.bottom, 28)
}
// Recently updated shelf
if !vm.recentlyUpdated.isEmpty {
ShelfHeader(title: "Recently Updated")
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 14) {
ForEach(vm.recentlyUpdated) { book in
NavigationLink(value: NavDestination.book(book.slug)) {
ShelfBookCard(book: book)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
.padding(.bottom, 4)
}
.padding(.bottom, 28)
}
// 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)
}
Color.clear.frame(height: 20)
}
}
.navigationTitle("Reading Now")
.appNavigationDestination()
.refreshable { await vm.load() }
.task { await vm.load() }
.errorAlert($vm.error)
}
}
}
// MARK: - Hero card (full-width, Apple Books "Now Playing" style)
private struct HeroContinueCard: View {
let item: ContinueReadingItem
var body: some View {
NavigationLink(value: NavDestination.chapter(item.book.slug, item.chapter)) {
ZStack(alignment: .bottomLeading) {
// Blurred background
KFImage(URL(string: item.book.cover))
.resizable()
.scaledToFill()
.frame(maxWidth: .infinity)
.frame(height: 220)
.blur(radius: 22)
.clipped()
// Depth gradient: subtle amber tint at top, deep shadow at bottom
.overlay(
LinearGradient(
stops: [
.init(color: Color(red: 0.18, green: 0.12, blue: 0.02).opacity(0.55), location: 0),
.init(color: .black.opacity(0.15), location: 0.35),
.init(color: .black.opacity(0.78), location: 1)
],
startPoint: .top,
endPoint: .bottom
)
)
// Content: cover on left, info stacked on right
HStack(alignment: .bottom, spacing: 14) {
KFImage(URL(string: item.book.cover))
.resizable()
.placeholder {
RoundedRectangle(cornerRadius: 10)
.fill(Color(.systemGray5))
}
.scaledToFill()
.frame(width: 96, height: 138)
.clipShape(RoundedRectangle(cornerRadius: 10))
.shadow(color: .black.opacity(0.55), radius: 12, y: 6)
.bookCoverZoomSource(slug: item.book.slug)
VStack(alignment: .leading, spacing: 6) {
// Progress indicator
if item.book.totalChapters > 0 {
let pct = min(1.0, Double(item.chapter) / Double(item.book.totalChapters))
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.2))
Capsule().fill(Color.amber.opacity(0.85))
.frame(width: geo.size.width * pct)
}
}
.frame(height: 3)
.frame(maxWidth: 140)
Text("\(Int(pct * 100))% complete")
.font(.caption2)
.foregroundStyle(.white.opacity(0.55))
}
Text(item.book.title)
.font(.headline)
.foregroundStyle(.white)
.lineLimit(2)
Text(item.book.author)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.65))
.lineLimit(1)
Spacer(minLength: 8)
HStack(spacing: 6) {
Image(systemName: "play.fill")
.font(.caption.bold())
Text("Continue Ch.\(item.chapter)")
.font(.subheadline.weight(.semibold))
}
.foregroundStyle(.black.opacity(0.85))
.padding(.horizontal, 14)
.padding(.vertical, 9)
.background(Capsule().fill(Color.amber))
}
Spacer(minLength: 0)
}
.padding(.horizontal, 16)
.padding(.bottom, 18)
}
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.25), radius: 14, y: 5)
}
.buttonStyle(.plain)
}
}
// MARK: - Shelf header
private struct ShelfHeader: View {
let title: String
var body: some View {
Text(title)
.font(.title3.bold())
.padding(.horizontal)
.padding(.bottom, 10)
}
}
// MARK: - Horizontal shelf: continue reading card
private struct ContinueReadingCard: View {
let item: ContinueReadingItem
private var progressFraction: Double {
guard item.book.totalChapters > 0 else { return 0 }
return min(1.0, Double(item.chapter) / Double(item.book.totalChapters))
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ZStack(alignment: .bottomTrailing) {
KFImage(URL(string: item.book.cover))
.resizable()
.placeholder {
RoundedRectangle(cornerRadius: 8)
.fill(Color(.systemGray5))
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
}
.scaledToFill()
.frame(width: 110, height: 158)
.clipShape(RoundedRectangle(cornerRadius: 8))
// Progress arc ring + chapter badge
ZStack {
Circle()
.stroke(Color.white.opacity(0.18), lineWidth: 2.5)
Circle()
.trim(from: 0, to: progressFraction)
.stroke(Color.amber, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
.rotationEffect(.degrees(-90))
Text("Ch.\(item.chapter)")
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.white)
.minimumScaleFactor(0.6)
}
.frame(width: 36, height: 36)
.background(.ultraThinMaterial, in: Circle())
.padding(5)
}
Text(item.book.title)
.font(.caption.bold())
.lineLimit(2)
.frame(width: 110, alignment: .leading)
}
}
}
// MARK: - Horizontal shelf: recently updated book card
private struct ShelfBookCard: View {
let book: Book
var body: some View {
VStack(alignment: .leading, spacing: 6) {
KFImage(URL(string: book.cover))
.resizable()
.placeholder {
RoundedRectangle(cornerRadius: 8)
.fill(Color(.systemGray5))
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
}
.scaledToFill()
.frame(width: 110, height: 158)
.clipShape(RoundedRectangle(cornerRadius: 8))
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
.bookCoverZoomSource(slug: book.slug)
Text(book.title)
.font(.caption.bold())
.lineLimit(2)
.frame(width: 110, alignment: .leading)
Text(book.author)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.frame(width: 110, alignment: .leading)
}
}
}
// MARK: - Stats strip (compact inline)
private struct StatsStrip: View {
let stats: HomeStats
var body: some View {
HStack(spacing: 0) {
StatPill(icon: "books.vertical.fill", value: "\(stats.totalBooks)", label: "Books")
Divider().frame(height: 28)
StatPill(icon: "text.alignleft", value: "\(stats.totalChapters)", label: "Chapters")
Divider().frame(height: 28)
StatPill(icon: "bookmark.fill", value: "\(stats.booksInProgress)", label: "In Progress")
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14))
}
}
private struct StatPill: View {
let icon: String
let value: String
let label: String
var body: some View {
VStack(spacing: 4) {
Image(systemName: icon)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Color.amber.opacity(0.8))
Text(value)
.font(.subheadline.bold().monospacedDigit())
.foregroundStyle(.primary)
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
}

View File

@@ -0,0 +1,370 @@
import SwiftUI
import Kingfisher
struct LibraryView: View {
@StateObject private var vm = LibraryViewModel()
@State private var sortOrder: SortOrder = .recentlyRead
@State private var readingFilter: ReadingFilter = .all
@State private var selectedGenre: String = "all"
@State private var searchText = ""
enum SortOrder: String, CaseIterable {
case recentlyRead = "Recent"
case title = "Title"
case author = "Author"
case progress = "Progress"
}
enum ReadingFilter: String, CaseIterable {
case all = "All"
case inProgress = "In Progress"
case completed = "Completed"
}
// All distinct genres across the library, sorted alphabetically.
private var availableGenres: [String] {
let all = vm.items.flatMap { $0.book.genres }
let unique = Array(Set(all)).sorted()
return unique
}
private var filtered: [LibraryItem] {
var result = vm.items
// 1. Reading filter
switch readingFilter {
case .all:
break
case .inProgress:
result = result.filter { !isCompleted($0) }
case .completed:
result = result.filter { isCompleted($0) }
}
// 2. Genre filter
if selectedGenre != "all" {
result = result.filter { $0.book.genres.contains(selectedGenre) }
}
// 3. Sort
switch sortOrder {
case .recentlyRead:
break // server returns by recency
case .title:
result = result.sorted { $0.book.title < $1.book.title }
case .author:
result = result.sorted { $0.book.author < $1.book.author }
case .progress:
result = result.sorted { ($0.lastChapter ?? 0) > ($1.lastChapter ?? 0) }
}
// 4. Search
if !searchText.isEmpty {
result = result.filter {
$0.book.title.localizedCaseInsensitiveContains(searchText) ||
$0.book.author.localizedCaseInsensitiveContains(searchText)
}
}
return result
}
private func isCompleted(_ item: LibraryItem) -> Bool {
// Treat as completed if book status is "completed" OR
// the user has read up to (or past) the total chapter count.
if item.book.status.lowercased() == "completed",
let ch = item.lastChapter,
item.book.totalChapters > 0,
ch >= item.book.totalChapters {
return true
}
return item.book.status.lowercased() == "completed" && (item.lastChapter ?? 0) > 0
}
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 {
VStack(spacing: 0) {
// Search bar
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search library", text: $searchText)
.font(.subheadline)
if !searchText.isEmpty {
Button { searchText = "" } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
.padding(.horizontal)
.padding(.top, 8)
// Reading filter (All / In Progress / Completed)
Picker("", selection: $readingFilter) {
ForEach(ReadingFilter.allCases, id: \.self) { f in
Text(f.rawValue).tag(f)
}
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.top, 12)
// Genre filter chips (only shown when genres are available)
if !availableGenres.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
// "All" chip
ChipButton(
label: "All",
isSelected: selectedGenre == "all",
style: .filled
) {
withAnimation { selectedGenre = "all" }
}
ForEach(availableGenres, id: \.self) { genre in
ChipButton(
label: genre.capitalized,
isSelected: selectedGenre == genre,
style: .filled
) {
withAnimation {
selectedGenre = selectedGenre == genre ? "all" : genre
}
}
}
}
.padding(.horizontal)
}
.padding(.top, 10)
}
// Sort chips
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(SortOrder.allCases, id: \.self) { order in
ChipButton(
label: order.rawValue,
isSelected: sortOrder == order,
style: .outlined
) {
withAnimation { sortOrder = order }
}
}
}
.padding(.horizontal)
}
.padding(.vertical, 10)
// Book count
Text("\(filtered.count) book\(filtered.count == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.padding(.bottom, 4)
if filtered.isEmpty {
VStack(spacing: 12) {
Image(systemName: readingFilter == .completed ? "checkmark.circle" : "book")
.font(.system(size: 40))
.foregroundStyle(.secondary)
Text(emptyMessage)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.top, 60)
} else {
// 3-column grid
LazyVGrid(
columns: [
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12)
],
spacing: 20
) {
ForEach(filtered) { item in
NavigationLink(value: NavDestination.book(item.book.slug)) {
LibraryBookCard(item: item)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
.padding(.bottom, 24)
}
}
}
}
}
.navigationTitle("Library")
.appNavigationDestination()
.refreshable { await vm.load() }
.task { await vm.load() }
.errorAlert($vm.error)
}
}
private var emptyMessage: String {
switch readingFilter {
case .all:
return selectedGenre == "all" ? "No books match your search." : "No \(selectedGenre.capitalized) books in your library."
case .inProgress:
return "No books in progress."
case .completed:
return "No completed books yet."
}
}
}
// MARK: - Genre filter chip
private struct FilterChipView: View {
let label: String
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Text(label)
.font(.caption.weight(isSelected ? .semibold : .regular))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
Capsule()
.fill(isSelected ? Color.amber : Color(.systemGray5))
)
.foregroundStyle(isSelected ? .white : .primary)
}
.buttonStyle(.plain)
}
}
// MARK: - Sort chip
private struct SortChip: View {
let label: String
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Text(label)
.font(.subheadline.weight(isSelected ? .semibold : .regular))
.padding(.horizontal, 14)
.padding(.vertical, 6)
.background(
Capsule()
.fill(isSelected ? Color.amber.opacity(0.15) : Color(.systemGray6))
.overlay(
Capsule()
.stroke(isSelected ? Color.amber : .clear, lineWidth: 1.5)
)
)
.foregroundStyle(isSelected ? .amber : .primary)
}
.buttonStyle(.plain)
}
}
// MARK: - Library book card (3-column)
private struct LibraryBookCard: View {
let item: LibraryItem
private var progressFraction: Double {
guard let ch = item.lastChapter, item.book.totalChapters > 0 else { return 0 }
return Double(ch) / Double(item.book.totalChapters)
}
private var isCompleted: Bool {
progressFraction >= 1.0
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ZStack(alignment: .topTrailing) {
// Cover image
KFImage(URL(string: item.book.cover))
.resizable()
.placeholder {
RoundedRectangle(cornerRadius: 8)
.fill(Color(.systemGray5))
.overlay(
Image(systemName: "book.closed")
.foregroundStyle(.secondary)
)
}
.scaledToFill()
.frame(maxWidth: .infinity)
.aspectRatio(2/3, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 8))
.shadow(color: .black.opacity(0.14), radius: 4, y: 2)
.bookCoverZoomSource(slug: item.book.slug)
// Progress arc or completed checkmark in top-right corner
if isCompleted {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
.background(Circle().fill(Color.amber).padding(1))
.padding(5)
} else if progressFraction > 0 {
ProgressArc(fraction: progressFraction)
.frame(width: 28, height: 28)
.padding(4)
}
}
// Title
Text(item.book.title)
.font(.caption.bold())
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
// Chapter badge if present
if let ch = item.lastChapter {
Text(isCompleted ? "Finished" : "Ch.\(ch)")
.font(.caption2)
.foregroundStyle(isCompleted ? Color.amber : .secondary)
}
}
}
}
// MARK: - Circular progress arc overlay
private struct ProgressArc: View {
let fraction: Double // 0...1
var body: some View {
ZStack {
Circle()
.fill(.ultraThinMaterial)
Circle()
.trim(from: 0, to: fraction)
.stroke(Color.amber, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.5), value: fraction)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,320 @@
import SwiftUI
import PhotosUI
import Kingfisher
struct ProfileView: View {
@EnvironmentObject var authStore: AuthStore
@StateObject private var vm = ProfileViewModel()
@State private var showChangePassword = false
// Avatar upload state
@State private var photoPickerItem: PhotosPickerItem?
@State private var avatarURL: String? = nil
@State private var avatarUploading = false
@State private var avatarError: String?
var body: some View {
NavigationStack {
List {
// User header
Section {
HStack(spacing: 16) {
// Tappable avatar circle
PhotosPicker(selection: $photoPickerItem,
matching: .images,
photoLibrary: .shared()) {
ZStack {
Circle()
.fill(Color(.systemGray5))
.frame(width: 72, height: 72)
if avatarUploading {
ProgressView()
.frame(width: 72, height: 72)
} else if let urlStr = avatarURL ?? authStore.user?.avatarURL,
let url = URL(string: urlStr) {
KFImage(url)
.placeholder {
Image(systemName: "person.circle.fill")
.font(.system(size: 52))
.foregroundStyle(.amber)
}
.resizable()
.scaledToFill()
.frame(width: 72, height: 72)
.clipShape(Circle())
} else {
Image(systemName: "person.circle.fill")
.font(.system(size: 52))
.foregroundStyle(.amber)
.frame(width: 72, height: 72)
}
// Camera overlay badge
if !avatarUploading {
VStack {
Spacer()
HStack {
Spacer()
ZStack {
Circle()
.fill(Color.amber)
.frame(width: 22, height: 22)
Image(systemName: "camera.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.black)
}
.offset(x: 2, y: 2)
}
}
.frame(width: 72, height: 72)
}
}
}
.buttonStyle(.plain)
.onChange(of: photoPickerItem) { _, item in
guard let item else { return }
Task { await uploadPickedPhoto(item) }
}
VStack(alignment: .leading, spacing: 3) {
Text(authStore.user?.username ?? "")
.font(.headline)
Text(authStore.user?.role.capitalized ?? "")
.font(.caption)
.foregroundStyle(.secondary)
if let err = avatarError {
Text(err)
.font(.caption2)
.foregroundStyle(.red)
}
}
}
.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()
}
.errorAlert($vm.error)
}
}
// MARK: - Avatar upload
private func uploadPickedPhoto(_ item: PhotosPickerItem) async {
avatarUploading = true
avatarError = nil
defer { avatarUploading = false }
do {
guard let data = try await item.loadTransferable(type: Data.self) else {
avatarError = "Could not read image"
return
}
// Compress to JPEG for consistent handling
let mimeType: String
let uploadData: Data
if let uiImage = UIImage(data: data),
let jpeg = uiImage.jpegData(compressionQuality: 0.85) {
uploadData = jpeg
mimeType = "image/jpeg"
} else {
uploadData = data
mimeType = "image/png"
}
let url = try await APIClient.shared.uploadAvatar(uploadData, mimeType: mimeType)
avatarURL = url
// Refresh user record so the new avatar persists across sessions
await authStore.validateToken()
} catch {
avatarError = "Upload failed: \(error.localizedDescription)"
}
}
// 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...2.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
try? await Task.sleep(nanoseconds: 1_200_000_000)
dismiss()
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}
}

View File

@@ -0,0 +1,9 @@
import XCTest
@testable import LibNovel
final class LibNovelTests: XCTestCase {
func testExample() throws {
// Placeholder add real tests here
XCTAssert(true)
}
}

View File

@@ -0,0 +1,36 @@
default_platform(:ios)
platform :ios do
desc "Build and upload to TestFlight"
lane :beta do
# Generate Xcode project from project.yml (one level up from fastlane/)
sh("cd .. && xcodegen generate --spec project.yml --project .")
# Set build number from CI run number (passed as env var)
increment_build_number(
build_number: ENV["BUILD_NUMBER"] || "1",
xcodeproj: "LibNovel.xcodeproj"
)
# Build the app - signing settings are in project.yml Release config
build_app(
scheme: "LibNovel",
export_method: "app-store",
clean: true,
configuration: "Release",
export_options: {
method: "app-store",
teamID: "GHZXC6FVMU",
provisioningProfiles: {
"com.kalekber.LibNovel" => "LibNovel Distribution"
},
signingStyle: "manual"
}
)
# Upload to TestFlight
upload_to_testflight(
skip_waiting_for_build_processing: true
)
end
end

91
ios/LibNovel/project.yml Normal file
View File

@@ -0,0 +1,91 @@
name: LibNovel
options:
bundleIdPrefix: com.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:
# 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
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.kalekber.LibNovel
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
TARGETED_DEVICE_FAMILY: "1,2" # iPhone + iPad
GENERATE_INFOPLIST_FILE: NO
INFOPLIST_FILE: LibNovel/Resources/Info.plist
configs:
Release:
CODE_SIGN_STYLE: Manual
DEVELOPMENT_TEAM: GHZXC6FVMU
CODE_SIGN_IDENTITY: "Apple Distribution"
PROVISIONING_PROFILE: "af592c3a-f60b-4ac1-a14f-30b8a206017f"
LibNovelTests:
type: bundle.unit-test
platform: iOS
deploymentTarget: "17.0"
sources:
- path: LibNovelTests
dependencies:
- target: LibNovel
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.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

32
ios/LibNovel/test-build.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/bin/bash
set -e
# Test script for local iOS build iteration
# Run from ios/LibNovel directory
echo "=== Generating Xcode project ==="
xcodegen generate --spec project.yml --project .
echo ""
echo "=== Listing available provisioning profiles ==="
ls -la ~/Library/MobileDevice/Provisioning\ Profiles/ || echo "No profiles found"
echo ""
echo "=== Listing available signing identities ==="
security find-identity -v -p codesigning
echo ""
echo "=== Attempting archive build ==="
xcodebuild archive \
-project LibNovel.xcodeproj \
-scheme LibNovel \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath ./build/LibNovel.xcarchive \
-allowProvisioningUpdates \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="Apple Distribution" \
DEVELOPMENT_TEAM="GHZXC6FVMU"
echo ""
echo "=== Build succeeded! ==="

View File

@@ -1,8 +1,13 @@
# justfile — libnovel-v2 task runner
# Install just: https://just.systems
scraper_dir := "scraper"
ui_dir := "ui"
scraper_dir := "scraper"
ui_dir := "ui"
ios_dir := "ios/LibNovel"
ios_scheme := "LibNovel"
ios_sim := "platform=iOS Simulator,name=iPhone 17"
ios_spm := ".spm-cache"
runner_temp := env_var_or_default("RUNNER_TEMP", "/tmp")
# ─── Build ────────────────────────────────────────────────────────────────────
@@ -18,7 +23,7 @@ build-all:
# Run unit tests only (no integration services required)
test:
cd {{scraper_dir}} && go test ./...
cd {{scraper_dir}} && go test -race -count=1 -timeout=60s ./...
# Run integration tests (requires MinIO, PocketBase, optional Browserless)
# Override env vars as needed, e.g.:
@@ -78,6 +83,92 @@ 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 .
# Resolve SPM package dependencies (cached to {{ios_spm}})
ios-resolve:
cd {{ios_dir}} && xcodebuild \
-project {{ios_scheme}}.xcodeproj \
-scheme {{ios_scheme}} \
-resolvePackageDependencies \
-clonedSourcePackagesDirPath {{ios_spm}}
# Build the iOS app for the simulator (no signing required)
# Runs ios-gen first to ensure the project is up to date.
ios-build: ios-gen ios-resolve
cd {{ios_dir}} && set -o pipefail && xcodebuild \
-project {{ios_scheme}}.xcodeproj \
-scheme {{ios_scheme}} \
-configuration Debug \
-destination 'generic/platform=iOS Simulator' \
-clonedSourcePackagesDirPath {{ios_spm}} \
CODE_SIGNING_ALLOWED=NO \
| xcpretty || xcodebuild \
-project {{ios_scheme}}.xcodeproj \
-scheme {{ios_scheme}} \
-configuration Debug \
-destination 'generic/platform=iOS Simulator' \
-clonedSourcePackagesDirPath {{ios_spm}} \
CODE_SIGNING_ALLOWED=NO
# Run unit tests on the simulator
# Runs ios-gen first to ensure the project is up to date.
ios-test: ios-gen ios-resolve
cd {{ios_dir}} && set -o pipefail && xcodebuild test \
-project {{ios_scheme}}.xcodeproj \
-scheme {{ios_scheme}} \
-configuration Debug \
-destination '{{ios_sim}}' \
-clonedSourcePackagesDirPath {{ios_spm}} \
CODE_SIGNING_ALLOWED=NO \
| xcpretty --report junit --output test-results.xml || true
# Archive a signed Release build (requires valid signing identity in keychain).
# Output: {{runner_temp}}/LibNovel.xcarchive
# Typically called from CI after importing certificate + provisioning profile.
# Usage: just ios-archive <team-id> <profile-uuid>
ios-archive team_id profile_uuid: ios-gen ios-resolve
cd {{ios_dir}} && xcodebuild archive \
-project {{ios_scheme}}.xcodeproj \
-scheme {{ios_scheme}} \
-configuration Release \
-destination 'generic/platform=iOS' \
-clonedSourcePackagesDirPath {{ios_spm}} \
-archivePath {{runner_temp}}/LibNovel.xcarchive \
CODE_SIGN_IDENTITY="Apple Distribution" \
"PROVISIONING_PROFILE[sdk=iphoneos*]={{profile_uuid}}" \
DEVELOPMENT_TEAM="{{team_id}}"
# Export an IPA from the archive produced by ios-archive.
# Requires ios/LibNovel/ExportOptions.plist.
# Output: {{runner_temp}}/ipa/LibNovel.ipa
ios-export:
cd {{ios_dir}} && xcodebuild -exportArchive \
-archivePath {{runner_temp}}/LibNovel.xcarchive \
-exportPath {{runner_temp}}/ipa \
-exportOptionsPlist ExportOptions.plist
# Set the build number (CFBundleVersion) in project.yml before archiving.
# Usage: just ios-set-build-number 42
ios-set-build-number number:
cd {{ios_dir}} && sed -i '' \
's/CURRENT_PROJECT_VERSION: .*/CURRENT_PROJECT_VERSION: {{number}}/' \
project.yml
# Upload the exported IPA to TestFlight via App Store Connect API.
# Requires env vars: ASC_KEY_ID, ASC_ISSUER_ID, ASC_PRIVATE_KEY_PATH
# The private key (.p8 file) must be present at ASC_PRIVATE_KEY_PATH.
ios-upload:
xcrun altool --upload-app \
--type ios \
--file {{runner_temp}}/ipa/LibNovel.ipa \
--apiKey "$ASC_KEY_ID" \
--apiIssuer "$ASC_ISSUER_ID"
# ─── Docker Compose ───────────────────────────────────────────────────────────
# Start all services (browserless, kokoro, scraper, minio, pocketbase)

View File

@@ -95,6 +95,7 @@ func run(log *slog.Logger) error {
BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"),
BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"),
BucketBrowse: envOr("MINIO_BUCKET_BROWSE", "libnovel-browse"),
BucketAvatars: envOr("MINIO_BUCKET_AVATARS", "libnovel-avatars"),
}
pbCfg := storage.PocketBaseConfig{
BaseURL: envOr("POCKETBASE_URL", "http://localhost:8090"),

View File

@@ -101,7 +101,7 @@ func newE2EFixture(t *testing.T) *e2eFixture {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg)
hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg, slog.Default())
if err != nil {
t.Fatalf("NewHybridStore: %v", err)
}

View File

@@ -151,6 +151,13 @@ func (s *mockStore) PresignChapter(_ context.Context, _ string, _ int, _ time.Du
func (s *mockStore) PresignAudio(_ context.Context, _ string, _ time.Duration) (string, error) {
return "", nil
}
func (s *mockStore) PresignAvatarUpload(_ context.Context, _, _ string) (string, string, error) {
return "", "", nil
}
func (s *mockStore) PresignAvatarURL(_ context.Context, _ string) (string, bool, error) {
return "", false, nil
}
func (s *mockStore) DeleteAvatar(_ context.Context, _ string) error { return nil }
func (s *mockStore) SaveBrowsePage(_ context.Context, _, _ string) error { return nil }
func (s *mockStore) GetBrowsePage(_ context.Context, _ string) (string, bool, error) {
return "", false, nil
@@ -173,6 +180,18 @@ func (s *mockStore) UpdateScrapeTask(_ context.Context, _ string, _ storage.Scra
func (s *mockStore) ListScrapeTasks(_ context.Context) ([]storage.ScrapeTask, error) {
return nil, nil
}
func (s *mockStore) CreateAudioJob(_ context.Context, _ string, _ int, _ string) (string, error) {
return "audio-job-id", nil
}
func (s *mockStore) UpdateAudioJob(_ context.Context, _, _, _ string, _ time.Time) error {
return nil
}
func (s *mockStore) GetAudioJob(_ context.Context, _ string) (storage.AudioJob, bool, error) {
return storage.AudioJob{}, false, nil
}
func (s *mockStore) ListAudioJobs(_ context.Context) ([]storage.AudioJob, error) {
return nil, nil
}
// ── helpers ───────────────────────────────────────────────────────────────────

View File

@@ -16,17 +16,15 @@ import (
//
// handleAudioGenerate handles POST /api/audio/{slug}/{n}.
//
// It calls Kokoro's POST /v1/audio/speech with return_download_link=true.
// Kokoro generates the audio, saves it to its own temp storage, and returns
// the download filename in the X-Download-Path response header.
// We cache that filename (in memory, keyed by slug/chapter/voice) and
// return a proxy URL that the browser sets as audio.src.
// The handler is non-blocking: it creates an audio_jobs record in PocketBase
// with status="pending", then fires a background goroutine to call Kokoro.
// The caller should poll GET /api/audio/status/{slug}/{n} to track progress.
//
// TTS is always generated at speed 1.0; playback speed is controlled
// client-side via the <audio> element's playbackRate.
// If audio is already cached (audio_cache hit) the handler returns
// status=200 with the proxy URL immediately — no job is created.
//
// On a cache hit the proxy URL is returned immediately without re-generating.
// Concurrent requests for the same key are deduplicated.
// Concurrent requests for the same key are deduplicated via audioJobIDs:
// the second caller gets a 202 with the existing job_id immediately.
func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
@@ -35,8 +33,7 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
return
}
// Parse optional voice from JSON body. Speed is intentionally ignored —
// TTS is always generated at 1.0; playback speed is applied client-side.
// Parse optional voice from JSON body.
voice := s.kokoroVoice
var body struct {
Voice string `json:"voice"`
@@ -58,84 +55,197 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
}
// Deduplicate concurrent generation for the same key.
// If a goroutine is already running for this key, return the existing job_id.
s.audioMu.Lock()
if ch, ok := s.audioInFlight[cacheKey]; ok {
if jobID, ok := s.audioJobIDs[cacheKey]; ok {
s.audioMu.Unlock()
select {
case <-ch:
case <-r.Context().Done():
http.Error(w, `{"error":"request cancelled"}`, http.StatusServiceUnavailable)
return
}
// Check store again after waiting.
if filename, ok := s.store.GetAudioCache(r.Context(), cacheKey); ok {
s.writeAudioResponse(w, slug, n, voice, filename)
} else {
http.Error(w, `{"error":"audio generation failed"}`, http.StatusInternalServerError)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(map[string]string{"job_id": jobID, "status": "generating"})
return
}
ch := make(chan struct{})
s.audioInFlight[cacheKey] = ch
// Create the PocketBase job record.
jobID, createErr := s.store.CreateAudioJob(r.Context(), slug, n, voice)
if createErr != nil {
s.audioMu.Unlock()
s.log.Warn("audio: failed to create job record", "slug", slug, "chapter", n, "err", createErr)
// Non-fatal: still proceed, just won't have a persistent job record.
jobID = ""
}
s.audioJobIDs[cacheKey] = jobID
s.audioMu.Unlock()
defer func() {
s.audioMu.Lock()
delete(s.audioInFlight, cacheKey)
s.audioMu.Unlock()
close(ch)
// Fire background goroutine — request context must NOT be used here since
// the handler returns immediately.
maxChars := body.MaxChars
go func() {
defer func() {
s.audioMu.Lock()
delete(s.audioJobIDs, cacheKey)
s.audioMu.Unlock()
}()
bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
s.runAudioGeneration(bgCtx, jobID, slug, n, voice, maxChars, cacheKey)
}()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(map[string]string{"job_id": jobID, "status": "pending"})
}
// runAudioGeneration performs the actual Kokoro TTS work in a goroutine.
// It updates the audio_jobs record as it progresses and writes to audio_cache
// and MinIO on success.
func (s *Server) runAudioGeneration(ctx context.Context, jobID, slug string, n int, voice string, maxChars int, cacheKey string) {
markFailed := func(msg string) {
if jobID == "" {
return
}
if err := s.store.UpdateAudioJob(ctx, jobID, "failed", msg, time.Now()); err != nil {
s.log.Warn("audio: failed to update job to failed", "job_id", jobID, "err", err)
}
}
// Transition to "generating".
if jobID != "" {
if err := s.store.UpdateAudioJob(ctx, jobID, "generating", "", time.Time{}); err != nil {
s.log.Warn("audio: failed to mark job generating", "job_id", jobID, "err", err)
}
}
// Load and validate chapter text.
raw, err := s.store.ReadChapter(r.Context(), slug, n)
raw, err := s.store.ReadChapter(ctx, slug, n)
if err != nil {
http.Error(w, `{"error":"chapter not found"}`, http.StatusNotFound)
s.log.Error("audio: chapter not found", "slug", slug, "chapter", n, "err", err)
markFailed("chapter not found")
return
}
text := stripMarkdown(raw)
if text == "" {
http.Error(w, `{"error":"chapter text is empty"}`, http.StatusUnprocessableEntity)
markFailed("chapter text is empty")
return
}
if body.MaxChars > 0 && len([]rune(text)) > body.MaxChars {
text = string([]rune(text)[:body.MaxChars])
if maxChars > 0 && len([]rune(text)) > maxChars {
text = string([]rune(text)[:maxChars])
}
if s.kokoroURL == "" {
http.Error(w, `{"error":"kokoro not configured"}`, http.StatusServiceUnavailable)
markFailed("kokoro not configured")
return
}
// Call Kokoro POST /v1/audio/speech at speed 1.0.
// Kokoro saves the generated audio to its own temp storage and returns the
// download path in the X-Download-Path response header.
filename, err := s.generateSpeech(r.Context(), text, voice, 1.0)
// Call Kokoro.
filename, err := s.generateSpeech(ctx, text, voice, 1.0)
if err != nil {
s.log.Error("kokoro speech generation failed", "slug", slug, "chapter", n, "err", err)
http.Error(w, `{"error":"speech generation failed"}`, http.StatusBadGateway)
s.log.Error("audio: kokoro speech generation failed", "slug", slug, "chapter", n, "err", err)
markFailed(err.Error())
return
}
if err := s.store.SetAudioCache(r.Context(), cacheKey, filename); err != nil {
s.log.Warn("audio cache write failed", "slug", slug, "chapter", n, "cache_key", cacheKey, "err", err)
if err := s.store.SetAudioCache(ctx, cacheKey, filename); err != nil {
s.log.Warn("audio: cache write failed", "slug", slug, "chapter", n, "err", err)
}
// Download generated audio from Kokoro and persist to MinIO synchronously
// so that the presigned URL returned to the client is immediately valid.
// Download from Kokoro and persist to MinIO.
minioKey := s.store.AudioObjectKey(slug, n, voice)
audioData, dlErr := s.downloadFromKokoro(r.Context(), filename)
audioData, dlErr := s.downloadFromKokoro(ctx, filename)
if dlErr != nil {
s.log.Warn("audio MinIO upload skipped: kokoro download failed",
s.log.Warn("audio: MinIO upload skipped: kokoro download failed",
"slug", slug, "chapter", n, "filename", filename, "err", dlErr)
} else if putErr := s.store.PutAudio(r.Context(), minioKey, audioData); putErr != nil {
s.log.Warn("audio MinIO upload failed",
} else if putErr := s.store.PutAudio(ctx, minioKey, audioData); putErr != nil {
s.log.Warn("audio: MinIO upload failed",
"slug", slug, "chapter", n, "key", minioKey, "err", putErr)
// upload failure is non-fatal; the client can still stream via Kokoro proxy
} else {
s.log.Info("audio uploaded to MinIO", "slug", slug, "chapter", n, "key", minioKey)
s.log.Info("audio: uploaded to MinIO", "slug", slug, "chapter", n, "key", minioKey)
}
s.log.Info("audio generated", "slug", slug, "chapter", n, "filename", filename)
s.writeAudioResponse(w, slug, n, voice, filename)
// Mark job done.
if jobID != "" {
if err := s.store.UpdateAudioJob(ctx, jobID, "done", "", time.Now()); err != nil {
s.log.Warn("audio: failed to mark job done", "job_id", jobID, "err", err)
}
}
s.log.Info("audio: generation complete", "slug", slug, "chapter", n, "filename", filename)
}
// handleAudioStatus handles GET /api/audio/status/{slug}/{n}.
// Returns the current generation status for the given chapter + voice.
//
// Query params: voice (optional, defaults to server default).
//
// Possible responses:
// - 200 {"status":"done","url":"/api/audio-proxy/..."} — audio ready
// - 200 {"status":"pending"|"generating","job_id":"..."} — in progress
// - 200 {"status":"idle"} — no job yet
// - 200 {"status":"failed","error":"..."} — last job failed
func (s *Server) handleAudioStatus(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 || slug == "" {
http.Error(w, `{"error":"invalid params"}`, http.StatusBadRequest)
return
}
voice := r.URL.Query().Get("voice")
if voice == "" {
voice = s.kokoroVoice
}
cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice)
w.Header().Set("Content-Type", "application/json")
// Fast path: audio already in audio_cache → done.
if filename, ok := s.store.GetAudioCache(r.Context(), cacheKey); ok {
proxyURL := fmt.Sprintf("/api/audio-proxy/%s/%d?voice=%s", slug, n, voice)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "done",
"url": proxyURL,
"filename": filename,
})
return
}
// Check in-flight map for live job ID.
s.audioMu.Lock()
liveJobID, inFlight := s.audioJobIDs[cacheKey]
s.audioMu.Unlock()
if inFlight {
// Look up persistent record for richer status.
if job, ok, _ := s.store.GetAudioJob(r.Context(), cacheKey); ok {
_ = json.NewEncoder(w).Encode(map[string]string{
"status": job.Status,
"job_id": liveJobID,
})
return
}
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "generating",
"job_id": liveJobID,
})
return
}
// Not in-flight: check persistent record for last known result.
job, ok, _ := s.store.GetAudioJob(r.Context(), cacheKey)
if !ok {
_ = json.NewEncoder(w).Encode(map[string]string{"status": "idle"})
return
}
resp := map[string]string{
"status": job.Status,
"job_id": job.ID,
}
if job.Status == "failed" && job.ErrorMessage != "" {
resp["error"] = job.ErrorMessage
}
_ = json.NewEncoder(w).Encode(resp)
}
// generateSpeech calls POST /v1/audio/speech on Kokoro with return_download_link=true
@@ -210,7 +320,7 @@ func (s *Server) downloadFromKokoro(ctx context.Context, filename string) ([]byt
return data, nil
}
// writeAudioResponse writes the JSON response for a generated audio chapter.
// writeAudioResponse writes the JSON response for an already-cached audio chapter.
// The URL points to our proxy handler GET /api/audio-proxy/{slug}/{n}.
func (s *Server) writeAudioResponse(w http.ResponseWriter, slug string, n int, voice string, filename string) {
proxyURL := fmt.Sprintf("/api/audio-proxy/%s/%d?voice=%s", slug, n, voice)
@@ -539,3 +649,64 @@ func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"url": url})
}
// handlePresignAvatarUpload handles GET /api/presign/avatar-upload/{userId}.
// Returns a short-lived presigned PUT URL for uploading an avatar image directly
// to MinIO, along with the object key to record in PocketBase after the upload.
// Query param: ext — image extension (jpg, png, webp). Defaults to "jpg".
func (s *Server) handlePresignAvatarUpload(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("userId")
if userID == "" {
http.Error(w, `{"error":"missing userId"}`, http.StatusBadRequest)
return
}
ext := r.URL.Query().Get("ext")
switch ext {
case "jpg", "jpeg":
ext = "jpg"
case "png":
ext = "png"
case "webp":
ext = "webp"
default:
ext = "jpg"
}
uploadURL, key, err := s.store.PresignAvatarUpload(r.Context(), userID, ext)
if err != nil {
s.log.Error("presign avatar upload failed", "userId", userID, "err", err)
http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"upload_url": uploadURL,
"key": key,
})
}
// handlePresignAvatar handles GET /api/presign/avatar/{userId}.
// Returns a presigned GET URL for a user's existing avatar, or 404 if none.
func (s *Server) handlePresignAvatar(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("userId")
if userID == "" {
http.Error(w, `{"error":"missing userId"}`, http.StatusBadRequest)
return
}
url, found, err := s.store.PresignAvatarURL(r.Context(), userID)
if err != nil {
s.log.Error("presign avatar failed", "userId", userID, "err", err)
http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError)
return
}
if !found {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"url": url})
}

View File

@@ -70,7 +70,7 @@ func newTestStore(t *testing.T) *storage.HybridStore {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg)
hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg, slog.Default())
if err != nil {
t.Fatalf("NewHybridStore: %v", err)
}

View File

@@ -11,7 +11,8 @@
// GET /api/presign/chapter/{slug}/{n} — presigned MinIO URL for chapter markdown
// GET /api/presign/audio/{slug}/{n} — presigned MinIO URL for chapter audio
// GET /api/chapter-text/{slug}/{n} — plain text of chapter (markdown stripped)
// POST /api/audio/{slug}/{n} — trigger Kokoro audio generation
// POST /api/audio/{slug}/{n} — trigger Kokoro audio generation (async, returns 202)
// GET /api/audio/status/{slug}/{n} — poll audio generation job status
// GET /api/audio-proxy/{slug}/{n} — proxy generated audio from Kokoro
package server
@@ -47,11 +48,11 @@ type Server struct {
voiceMu sync.RWMutex
cachedVoices []string // populated on first request from Kokoro /v1/audio/voices
// audioMu guards audioInFlight only.
// audioMu guards audioJobIDs only.
// Completed audio filenames are persisted to the Store (PocketBase).
// audioInFlight deduplicates concurrent generation requests for the same key.
audioMu sync.Mutex
audioInFlight map[string]chan struct{} // cacheKey → closed when done
// audioJobIDs deduplicates concurrent generation requests for the same key.
audioMu sync.Mutex
audioJobIDs map[string]string // cacheKey → PocketBase job ID (empty string if record creation failed)
// browseMu guards browseInFlight — keys currently being refreshed
// in the background.
@@ -82,7 +83,7 @@ func New(addr string, oCfg orchestrator.Config, novel scraper.NovelScraper, log
store: store,
kokoroURL: kokoroURL,
kokoroVoice: kokoroVoice,
audioInFlight: make(map[string]chan struct{}),
audioJobIDs: make(map[string]string),
browseInFlight: make(map[string]struct{}),
browseMemCache: make(map[string]browseCacheEntry),
}
@@ -164,6 +165,8 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("GET /api/presign/chapter/{slug}/{n}", s.handlePresignChapter)
mux.HandleFunc("GET /api/presign/audio/{slug}/{n}", s.handlePresignAudio)
mux.HandleFunc("GET /api/presign/voice-sample/{voice}", s.handlePresignVoiceSample)
mux.HandleFunc("GET /api/presign/avatar-upload/{userId}", s.handlePresignAvatarUpload)
mux.HandleFunc("GET /api/presign/avatar/{userId}", s.handlePresignAvatar)
// Plain-text chapter content (used server-side for audio generation)
mux.HandleFunc("GET /api/chapter-text/{slug}/{n}", s.handleChapterText)
// Voices list (proxied from Kokoro)
@@ -177,13 +180,11 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
)
mux.Handle("POST /api/audio/voice-samples", voiceSampleHandler)
// Server-side audio generation via Kokoro /v1/audio/speech.
// Generation can take several minutes, so wrap in its own timeout handler.
audioGenHandler := http.TimeoutHandler(
http.HandlerFunc(s.handleAudioGenerate),
10*time.Minute,
`{"error":"audio generation timed out"}`,
)
mux.Handle("POST /api/audio/{slug}/{n}", audioGenHandler)
// POST returns 202 immediately and starts a background goroutine;
// poll GET /api/audio/status/{slug}/{n} to track progress.
mux.HandleFunc("POST /api/audio/{slug}/{n}", s.handleAudioGenerate)
// Audio job status polling endpoint.
mux.HandleFunc("GET /api/audio/status/{slug}/{n}", s.handleAudioStatus)
// Proxy route: fetches the generated file from Kokoro /v1/download/{filename}.
mux.HandleFunc("GET /api/audio-proxy/{slug}/{n}", s.handleAudioProxy)

View File

@@ -316,6 +316,18 @@ func (h *HybridStore) PresignAudio(ctx context.Context, key string, expires time
return h.minio.PresignAudio(ctx, key, expires)
}
func (h *HybridStore) PresignAvatarUpload(ctx context.Context, userID, ext string) (string, string, error) {
return h.minio.PresignAvatarUploadURL(ctx, userID, ext)
}
func (h *HybridStore) PresignAvatarURL(ctx context.Context, userID string) (string, bool, error) {
return h.minio.PresignAvatarURL(ctx, userID)
}
func (h *HybridStore) DeleteAvatar(ctx context.Context, userID string) error {
return h.minio.DeleteAvatar(ctx, userID)
}
// ─── Browse page snapshots ────────────────────────────────────────────────────
func (h *HybridStore) SaveBrowsePage(ctx context.Context, key, html string) error {
@@ -396,6 +408,66 @@ func (h *HybridStore) ListScrapeTasks(ctx context.Context) ([]ScrapeTask, error)
return tasks, nil
}
// ─── Audio jobs ───────────────────────────────────────────────────────────────
func (h *HybridStore) CreateAudioJob(ctx context.Context, slug string, chapter int, voice string) (string, error) {
return h.pb.CreateAudioJob(ctx, slug, chapter, voice)
}
func (h *HybridStore) UpdateAudioJob(ctx context.Context, id, status, errMsg string, finished time.Time) error {
return h.pb.UpdateAudioJob(ctx, id, status, errMsg, finished)
}
func (h *HybridStore) GetAudioJob(ctx context.Context, cacheKey string) (AudioJob, bool, error) {
rec, ok, err := h.pb.GetAudioJob(ctx, cacheKey)
if err != nil || !ok {
return AudioJob{}, ok, err
}
job := AudioJob{
ID: strVal(rec, "id"),
CacheKey: strVal(rec, "cache_key"),
Slug: strVal(rec, "slug"),
Chapter: int(floatVal(rec, "chapter")),
Voice: strVal(rec, "voice"),
Status: strVal(rec, "status"),
ErrorMessage: strVal(rec, "error_message"),
}
if ts, ok := rec["started"].(string); ok {
job.Started, _ = time.Parse(time.RFC3339, ts)
}
if ts, ok := rec["finished"].(string); ok && ts != "" {
job.Finished, _ = time.Parse(time.RFC3339, ts)
}
return job, true, nil
}
func (h *HybridStore) ListAudioJobs(ctx context.Context) ([]AudioJob, error) {
rows, err := h.pb.ListAudioJobs(ctx)
if err != nil {
return nil, err
}
jobs := make([]AudioJob, 0, len(rows))
for _, r := range rows {
job := AudioJob{
ID: strVal(r, "id"),
CacheKey: strVal(r, "cache_key"),
Slug: strVal(r, "slug"),
Chapter: int(floatVal(r, "chapter")),
Voice: strVal(r, "voice"),
Status: strVal(r, "status"),
ErrorMessage: strVal(r, "error_message"),
}
if ts, ok := r["started"].(string); ok {
job.Started, _ = time.Parse(time.RFC3339, ts)
}
if ts, ok := r["finished"].(string); ok && ts != "" {
job.Finished, _ = time.Parse(time.RFC3339, ts)
}
jobs = append(jobs, job)
}
return jobs, nil
}
// ─── helpers ──────────────────────────────────────────────────────────────────
func recToBookMeta(rec map[string]interface{}) scraper.BookMeta {

View File

@@ -23,6 +23,7 @@ type MinioConfig struct {
BucketChapters string // e.g. "libnovel-chapters"
BucketAudio string // e.g. "libnovel-audio"
BucketBrowse string // e.g. "libnovel-browse"
BucketAvatars string // e.g. "libnovel-avatars"
}
// MinioClient wraps a minio.Client and exposes object operations for
@@ -59,7 +60,7 @@ func NewMinioClient(ctx context.Context, cfg MinioConfig) (*MinioClient, error)
}
mc := &MinioClient{c: c, pub: pub, cfg: cfg}
for _, bucket := range []string{cfg.BucketChapters, cfg.BucketAudio, cfg.BucketBrowse} {
for _, bucket := range []string{cfg.BucketChapters, cfg.BucketAudio, cfg.BucketBrowse, cfg.BucketAvatars} {
if bucket == "" {
continue
}
@@ -336,3 +337,77 @@ func sanitiseVoice(voice string) string {
return '_'
}, voice)
}
// ─── Avatar objects ───────────────────────────────────────────────────────────
// avatarKey returns the MinIO object key for a user avatar.
// Layout: avatars/{userId}.{ext}
func avatarKey(userID, ext string) string {
return fmt.Sprintf("avatars/%s.%s", userID, ext)
}
// PutAvatar stores an avatar image in the avatars bucket.
// ext should be "jpg", "png", or "webp".
func (m *MinioClient) PutAvatar(ctx context.Context, userID, ext string, data []byte, contentType string) error {
if m.cfg.BucketAvatars == "" {
return fmt.Errorf("minio: avatars bucket not configured")
}
key := avatarKey(userID, ext)
_, err := m.c.PutObject(ctx, m.cfg.BucketAvatars, key,
bytes.NewReader(data), int64(len(data)),
minio.PutObjectOptions{ContentType: contentType})
if err != nil {
return fmt.Errorf("minio: put avatar %s: %w", key, err)
}
return nil
}
// PresignAvatarUploadURL returns a presigned PUT URL for uploading an avatar image
// directly to MinIO from the client. Signed with the public endpoint so iOS/browser
// can PUT bytes straight to MinIO without routing through the server.
// ext should be "jpg", "png", or "webp". Expires in 15 minutes.
func (m *MinioClient) PresignAvatarUploadURL(ctx context.Context, userID, ext string) (string, string, error) {
if m.cfg.BucketAvatars == "" {
return "", "", fmt.Errorf("minio: avatars bucket not configured")
}
key := avatarKey(userID, ext)
u, err := m.pub.PresignedPutObject(ctx, m.cfg.BucketAvatars, key, 15*time.Minute)
if err != nil {
return "", "", fmt.Errorf("minio: presign avatar upload %s: %w", key, err)
}
return u.String(), key, nil
}
// PresignAvatarURL returns a presigned GET URL for a user avatar.
// Returns ("", false, nil) when no avatar exists for the given userID.
func (m *MinioClient) PresignAvatarURL(ctx context.Context, userID string) (string, bool, error) {
if m.cfg.BucketAvatars == "" {
return "", false, nil
}
// Try common extensions in order of preference.
for _, ext := range []string{"jpg", "png", "webp", "gif"} {
key := avatarKey(userID, ext)
_, statErr := m.c.StatObject(ctx, m.cfg.BucketAvatars, key, minio.StatObjectOptions{})
if statErr != nil {
continue
}
u, err := m.pub.PresignedGetObject(ctx, m.cfg.BucketAvatars, key, 24*time.Hour, nil)
if err != nil {
return "", false, fmt.Errorf("minio: presign avatar %s: %w", key, err)
}
return u.String(), true, nil
}
return "", false, nil
}
// DeleteAvatar removes any existing avatar for the given userID (all extensions).
func (m *MinioClient) DeleteAvatar(ctx context.Context, userID string) error {
if m.cfg.BucketAvatars == "" {
return nil
}
for _, ext := range []string{"jpg", "png", "webp", "gif"} {
key := avatarKey(userID, ext)
_ = m.c.RemoveObject(ctx, m.cfg.BucketAvatars, key, minio.RemoveObjectOptions{})
}
return nil
}

View File

@@ -14,6 +14,8 @@
// books_found(number), chapters_scraped(number),
// chapters_skipped(number), errors(number),
// started(date), finished(date), error_message(text)
// user_sessions — user_id(text), session_id(text,unique), user_agent(text),
// ip(text), created_at(date), last_seen(date)
package storage
import (
@@ -394,6 +396,32 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error {
{"name": "error_message", "type": "text"},
},
},
{
"name": "audio_jobs",
"type": "base",
"fields": []map[string]interface{}{
{"name": "cache_key", "type": "text", "required": true}, // "slug/chapter/voice"
{"name": "slug", "type": "text", "required": true},
{"name": "chapter", "type": "number"},
{"name": "voice", "type": "text"},
{"name": "status", "type": "text", "required": true}, // "pending" | "generating" | "done" | "failed"
{"name": "error_message", "type": "text"},
{"name": "started", "type": "date"},
{"name": "finished", "type": "date"},
},
},
{
"name": "user_sessions",
"type": "base",
"fields": []map[string]interface{}{
{"name": "user_id", "type": "text", "required": true},
{"name": "session_id", "type": "text", "required": true}, // random ID embedded in auth token
{"name": "user_agent", "type": "text"},
{"name": "ip", "type": "text"},
{"name": "created_at", "type": "date"},
{"name": "last_seen", "type": "date"},
},
},
}
for _, col := range collections {
name, _ := col["name"].(string)
@@ -430,6 +458,8 @@ type migration struct {
var migrations = []migration{
// user_id was added to progress after initial deploy.
{"progress", "user_id", "text"},
// avatar_url stores the MinIO presign path for the user's profile picture.
{"app_users", "avatar_url", "text"},
}
// EnsureMigrations idempotently adds any fields that are missing from existing
@@ -784,6 +814,76 @@ func (s *PocketBaseStore) ListScrapingTasks(ctx context.Context) ([]map[string]i
return s.pb.listAll(ctx, "scraping_tasks", "", "-started")
}
// ─── Audio jobs ───────────────────────────────────────────────────────────────
// CreateAudioJob inserts a new audio_jobs record with status="pending".
func (s *PocketBaseStore) CreateAudioJob(ctx context.Context, slug string, chapter int, voice string) (string, error) {
cacheKey := fmt.Sprintf("%s/%d/%s", slug, chapter, voice)
data := map[string]interface{}{
"cache_key": cacheKey,
"slug": slug,
"chapter": chapter,
"voice": voice,
"status": "pending",
"started": time.Now().UTC().Format(time.RFC3339),
}
resp, err := s.pb.do(ctx, http.MethodPost, "/api/collections/audio_jobs/records", data)
if err != nil {
return "", err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("pocketbase: CreateAudioJob: status %d: %s", resp.StatusCode, b)
}
var rec map[string]interface{}
if err := json.Unmarshal(b, &rec); err != nil {
return "", fmt.Errorf("pocketbase: CreateAudioJob: decode: %w", err)
}
id, _ := rec["id"].(string)
return id, nil
}
// UpdateAudioJob patches status, error_message, and optionally finished on an audio_jobs record.
func (s *PocketBaseStore) UpdateAudioJob(ctx context.Context, id, status, errMsg string, finished time.Time) error {
data := map[string]interface{}{
"status": status,
"error_message": errMsg,
}
if !finished.IsZero() {
data["finished"] = finished.UTC().Format(time.RFC3339)
}
resp, err := s.pb.do(ctx, http.MethodPatch,
fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), data)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("pocketbase: UpdateAudioJob id=%s: status %d: %s", id, resp.StatusCode, b)
}
return nil
}
// GetAudioJob returns the most recent audio_jobs record for the given cache key.
func (s *PocketBaseStore) GetAudioJob(ctx context.Context, cacheKey string) (map[string]interface{}, bool, error) {
rec, err := s.pb.listOne(ctx, "audio_jobs",
fmt.Sprintf(`cache_key="%s"`, pbEsc(cacheKey)))
if err != nil {
return nil, false, err
}
if rec == nil {
return nil, false, nil
}
return rec, true, nil
}
// ListAudioJobs returns all audio_jobs sorted by started descending.
func (s *PocketBaseStore) ListAudioJobs(ctx context.Context) ([]map[string]interface{}, error) {
return s.pb.listAll(ctx, "audio_jobs", "", "-started")
}
// ─── helpers ──────────────────────────────────────────────────────────────────
// pbEsc escapes a string for use in a PocketBase filter expression.

View File

@@ -30,6 +30,20 @@ type ReadingProgress struct {
UpdatedAt time.Time `json:"updated_at"`
}
// AudioJob represents a single audio-generation job record from the
// audio_jobs collection.
type AudioJob struct {
ID string `json:"id"`
CacheKey string `json:"cache_key"` // "slug/chapter/voice"
Slug string `json:"slug"`
Chapter int `json:"chapter"`
Voice string `json:"voice"`
Status string `json:"status"` // "pending" | "generating" | "done" | "failed"
ErrorMessage string `json:"error_message,omitempty"`
Started time.Time `json:"started"`
Finished time.Time `json:"finished,omitempty"`
}
// ScrapeTask represents a single scraping job record from the scraping_tasks
// collection.
type ScrapeTask struct {
@@ -146,6 +160,17 @@ type Store interface {
// PresignAudio returns a presigned GET URL for an audio object.
PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error)
// PresignAvatarUpload returns a short-lived presigned PUT URL for uploading
// an avatar image directly to MinIO, and the object key that will be stored.
// ext should be "jpg", "png", or "webp".
PresignAvatarUpload(ctx context.Context, userID, ext string) (uploadURL, key string, err error)
// PresignAvatarURL returns a presigned GET URL for a user's avatar, or ("", false, nil) if none.
PresignAvatarURL(ctx context.Context, userID string) (string, bool, error)
// DeleteAvatar removes all avatar objects for a user (all extensions).
DeleteAvatar(ctx context.Context, userID string) error
// ── Browse page snapshots (MinIO) ──────────────────────────────────────
// SaveBrowsePage stores a SingleFile HTML snapshot for the given cache key.
@@ -177,4 +202,17 @@ type Store interface {
UpdateScrapeTask(ctx context.Context, id string, u ScrapeTaskUpdate) error
// ListScrapeTasks returns all tasks sorted by started descending.
ListScrapeTasks(ctx context.Context) ([]ScrapeTask, error)
// ── Audio jobs ─────────────────────────────────────────────────────────
// CreateAudioJob inserts a new audio_jobs record with status="pending"
// and returns the assigned ID.
CreateAudioJob(ctx context.Context, slug string, chapter int, voice string) (string, error)
// UpdateAudioJob patches an existing audio job record (status, error, finished).
UpdateAudioJob(ctx context.Context, id, status, errMsg string, finished time.Time) error
// GetAudioJob returns the most recent audio job for the given cache key,
// or (zero, false, nil) if none exists.
GetAudioJob(ctx context.Context, cacheKey string) (AudioJob, bool, error)
// ListAudioJobs returns all audio jobs sorted by started descending.
ListAudioJobs(ctx context.Context) ([]AudioJob, error)
}

View File

@@ -177,7 +177,8 @@ create_collection "app_users" '{
{"name": "username", "type": "text", "required": true},
{"name": "password_hash", "type": "text", "required": true},
{"name": "role", "type": "text"},
{"name": "created", "type": "date"}
{"name": "created", "type": "date"},
{"name": "avatar_url", "type": "text"}
]
}'
@@ -200,5 +201,6 @@ create_collection "user_settings" '{
ensure_field "progress" "user_id" "text"
ensure_field "progress" "audio_time" "number"
ensure_field "user_settings" "user_id" "text"
ensure_field "app_users" "avatar_url" "text"
log "all collections ready"

View File

@@ -0,0 +1,39 @@
log:
level: info
runner:
file: .runner
capacity: 1
envs: {}
env_file: .env
timeout: 3h
shutdown_timeout: 0s
insecure: false
fetch_timeout: 5s
fetch_interval: 2s
github_mirror: ''
labels:
- "macos-latest:host"
- "macos-14:host"
cache:
enabled: true
dir: ""
host: "__HOST_IP__"
port: 8088
external_server: ""
container:
network: ""
privileged: false
options: ""
workdir_parent: ""
valid_volumes: []
docker_host: ""
force_pull: false
force_rebuild: false
require_docker: false
docker_timeout: 0s
host:
workdir_parent: ""

123
scripts/setup_runner_mac.sh Executable file
View File

@@ -0,0 +1,123 @@
#!/usr/bin/env bash
set -euo pipefail
# ── setup_runner_mac.sh ───────────────────────────────────────────────────────
# Sets up act_runner as a host-mode runner on macOS for iOS CI/CD.
# Installs the binary, generates a config, registers against Gitea,
# and installs a LaunchDaemon so the runner starts at boot.
#
# Usage: sudo ./setup_runner_mac.sh <runner-name>
# Example: sudo ./setup_runner_mac.sh mac-runner-1
# ─────────────────────────────────────────────────────────────────────────────
usage() {
echo "Usage: sudo $0 <runner-name>"
exit 1
}
[[ $# -ne 1 ]] && usage
[[ "$EUID" -ne 0 ]] && { echo "ERROR: run with sudo"; exit 1; }
RUNNER_NAME="$1"
GITEA_URL="https://gitea.kalekber.cc/"
REGISTRATION_TOKEN="AboxpDKWx7gizwJ9xeheHVqKjj9J9N9BgyX96wvu"
CACHE_PORT=8088
INSTALL_DIR="/usr/local/bin"
WORK_DIR="/var/lib/act_runner"
CONFIG_PATH="/etc/act_runner/config.yaml"
LAUNCHDAEMON_PLIST="/Library/LaunchDaemons/com.gitea.act_runner.plist"
# ── detect Mac LAN IP ─────────────────────────────────────────────────────────
HOST_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "")
if [[ -z "$HOST_IP" ]]; then
echo "ERROR: could not detect LAN IP via en0/en1. Set cache.host manually in $CONFIG_PATH"
HOST_IP="127.0.0.1"
fi
echo "Host LAN IP: $HOST_IP"
# ── download act_runner binary ────────────────────────────────────────────────
ARCH=$(uname -m)
if [[ "$ARCH" == "arm64" ]]; then
BINARY_URL="https://gitea.com/gitea/act_runner/releases/download/v0.3.0/act_runner-0.3.0-darwin-arm64"
else
BINARY_URL="https://gitea.com/gitea/act_runner/releases/download/v0.3.0/act_runner-0.3.0-darwin-amd64"
fi
echo "Downloading act_runner for $ARCH..."
curl -fsSL "$BINARY_URL" -o "$INSTALL_DIR/act_runner"
chmod +x "$INSTALL_DIR/act_runner"
echo "Installed: $("$INSTALL_DIR/act_runner" --version)"
# ── create working directory ──────────────────────────────────────────────────
mkdir -p "$WORK_DIR"
mkdir -p "$(dirname "$CONFIG_PATH")"
# ── install config ────────────────────────────────────────────────────────────
# Use the checked-in static config and substitute the LAN IP placeholder.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
sed "s/__HOST_IP__/$HOST_IP/" "$SCRIPT_DIR/runner-config-mac.yaml" > "$CONFIG_PATH"
echo "Config written: labels=macos-latest:host, cache=$HOST_IP:$CACHE_PORT"
# ── register runner ───────────────────────────────────────────────────────────
echo "Registering runner '$RUNNER_NAME'..."
"$INSTALL_DIR/act_runner" register \
--no-interactive \
--config "$CONFIG_PATH" \
--instance "$GITEA_URL" \
--token "$REGISTRATION_TOKEN" \
--name "$RUNNER_NAME" \
--labels "macos-latest:host,macos-14:host"
# Copy .runner file to work dir if it was created in cwd
[[ -f ".runner" ]] && cp .runner "$WORK_DIR/.runner"
# ── install LaunchDaemon ──────────────────────────────────────────────────────
# PATH must include Homebrew + Xcode tools so xcodebuild, xcrun, npm, etc. are found.
HOMEBREW_PREFIX=$([ "$ARCH" = "arm64" ] && echo "/opt/homebrew" || echo "/usr/local")
cat > "$LAUNCHDAEMON_PLIST" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.gitea.act_runner</string>
<key>ProgramArguments</key>
<array>
<string>${INSTALL_DIR}/act_runner</string>
<string>daemon</string>
<string>--config</string>
<string>${CONFIG_PATH}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>WorkingDirectory</key>
<string>${WORK_DIR}</string>
<key>StandardOutPath</key>
<string>${WORK_DIR}/act_runner.log</string>
<key>StandardErrorPath</key>
<string>${WORK_DIR}/act_runner.err</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>${HOMEBREW_PREFIX}/bin:${HOMEBREW_PREFIX}/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Applications/Xcode.app/Contents/Developer/usr/bin</string>
<key>HOME</key>
<string>${WORK_DIR}</string>
</dict>
</dict>
</plist>
PLIST
echo "LaunchDaemon written to $LAUNCHDAEMON_PLIST"
# ── load the daemon ───────────────────────────────────────────────────────────
launchctl unload "$LAUNCHDAEMON_PLIST" 2>/dev/null || true
launchctl load "$LAUNCHDAEMON_PLIST"
echo "Runner '$RUNNER_NAME' started via LaunchDaemon"
echo ""
echo "Useful commands:"
echo " View logs: tail -f $WORK_DIR/act_runner.log"
echo " Stop runner: sudo launchctl unload $LAUNCHDAEMON_PLIST"
echo " Start runner: sudo launchctl load $LAUNCHDAEMON_PLIST"

39
scripts/test-ci-signing.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")/../ios/LibNovel"
echo "=== Testing CI-like signing process ==="
# 1. Install provisioning profile (simulate CI)
PP_PATH=~/Downloads/LibNovel_Distribution.mobileprovision
UUID=$(security cms -D -i "$PP_PATH" | plutil -extract UUID raw -)
PROFILE_NAME=$(security cms -D -i "$PP_PATH" | plutil -extract Name raw -)
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp "$PP_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
echo "Installed profile: $PROFILE_NAME (UUID: $UUID)"
# 2. Generate Xcode project
echo "Generating Xcode project..."
xcodegen generate --spec project.yml --project .
# 3. List available provisioning profiles
echo -e "\n=== Available provisioning profiles ==="
ls -la ~/Library/MobileDevice/Provisioning\ Profiles/
# 4. Try building with xcodebuild using manual signing
echo -e "\n=== Attempting archive with manual signing ==="
xcodebuild archive \
-scheme LibNovel \
-project LibNovel.xcodeproj \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath /tmp/LibNovel.xcarchive \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="Apple Distribution: Kamil Alekberov (GHZXC6FVMU)" \
DEVELOPMENT_TEAM=GHZXC6FVMU \
PROVISIONING_PROFILE_SPECIFIER="$UUID" \
| xcpretty || true
echo -e "\n=== Build complete ==="

View File

@@ -0,0 +1,53 @@
#!/bin/bash
# Simple iOS build test without fastlane
# Run from project root: ./scripts/test-ios-build-simple.sh /path/to/profile.mobileprovision
set -e
PROFILE_PATH="$1"
if [ -z "$PROFILE_PATH" ]; then
echo "Usage: $0 /path/to/profile.mobileprovision"
exit 1
fi
echo "=== Step 1: Extract profile info ==="
UUID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract UUID raw -)
PROFILE_NAME=$(security cms -D -i "$PROFILE_PATH" | plutil -extract Name raw -)
TEAM_ID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract TeamIdentifier.0 raw -)
echo "Profile Name: $PROFILE_NAME"
echo "UUID: $UUID"
echo "Team ID: $TEAM_ID"
echo ""
echo "=== Step 2: Install profile ==="
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
echo "✓ Installed"
echo ""
echo "=== Step 3: Check signing identities ==="
security find-identity -v -p codesigning
echo ""
echo "=== Step 4: Generate Xcode project ==="
cd ios/LibNovel
export USER=runner
xcodegen generate --spec project.yml --project .
echo "✓ Project generated"
echo ""
echo "=== Step 5: Try automatic signing build ==="
xcodebuild archive \
-project LibNovel.xcodeproj \
-scheme LibNovel \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath ./build/LibNovel.xcarchive \
-allowProvisioningUpdates \
CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM="$TEAM_ID"
echo ""
echo "=== ✓ BUILD SUCCEEDED! ==="

58
scripts/test-ios-build.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/bin/bash
# Local build test script
# Run from the project root: ./scripts/test-ios-build.sh /path/to/your/profile.mobileprovision
set -e
PROFILE_PATH="$1"
if [ -z "$PROFILE_PATH" ]; then
echo "Usage: $0 /path/to/profile.mobileprovision"
exit 1
fi
if [ ! -f "$PROFILE_PATH" ]; then
echo "Error: Profile not found at $PROFILE_PATH"
exit 1
fi
echo "=== Extracting profile info ==="
UUID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract UUID raw -)
PROFILE_NAME=$(security cms -D -i "$PROFILE_PATH" | plutil -extract Name raw -)
BUNDLE_ID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract Entitlements.application-identifier raw - 2>/dev/null | sed 's/.*\.//')
TEAM_ID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract TeamIdentifier.0 raw -)
echo "Profile Name: $PROFILE_NAME"
echo "UUID: $UUID"
echo "Bundle ID: $BUNDLE_ID"
echo "Team ID: $TEAM_ID"
echo ""
echo "=== Installing provisioning profile ==="
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
echo "Installed to: ~/Library/MobileDevice/Provisioning Profiles/$UUID.mobileprovision"
echo ""
echo "=== Listing signing identities ==="
security find-identity -v -p codesigning
echo ""
echo "=== Navigating to iOS project ==="
cd ios/LibNovel
echo ""
echo "=== Generating Xcode project ==="
xcodegen generate --spec project.yml --project .
echo ""
echo "=== Testing fastlane build ==="
export USER=runner
export BUILD_NUMBER=999
export PROVISIONING_PROFILE_NAME="$PROFILE_NAME"
# Run fastlane beta lane
fastlane beta --verbose
echo ""
echo "=== Build succeeded! ==="

1673
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,8 @@
"vite": "^7.3.1"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0",
"marked": "^17.0.3",
"pocketbase": "^0.26.8"
}

4
ui/src/app.d.ts vendored
View File

@@ -5,10 +5,10 @@ declare global {
// interface Error {}
interface Locals {
sessionId: string;
user: { id: string; username: string; role: string } | null;
user: { id: string; username: string; role: string; authSessionId: string } | null;
}
interface PageData {
user?: { id: string; username: string; role: string } | null;
user?: { id: string; username: string; role: string; authSessionId: string } | null;
}
// interface PageState {}
// interface Platform {}

View File

@@ -3,6 +3,12 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32" />
<link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="/favicon-16.png" sizes="16x16" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" href="/icon-192.png" sizes="192x192" />
<link rel="icon" type="image/png" href="/icon-512.png" sizes="512x512" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View File

@@ -2,6 +2,7 @@ import type { Handle } from '@sveltejs/kit';
import { randomBytes, createHmac } from 'node:crypto';
import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
import { createUserSession, touchUserSession, isSessionRevoked } from '$lib/server/pocketbase';
const SESSION_COOKIE = 'libnovel_session';
const AUTH_COOKIE = 'libnovel_auth';
@@ -40,27 +41,30 @@ export function verifyToken(token: string): string | null {
/**
* Create a signed auth token for a user.
* Payload format: "<userId>:<username>:<role>"
* Payload format: "<userId>:<username>:<role>:<authSessionId>"
* authSessionId uniquely identifies this login session (for revocation).
*/
export function createAuthToken(userId: string, username: string, role: string): string {
return signToken(`${userId}:${username}:${role}`);
export function createAuthToken(userId: string, username: string, role: string, authSessionId: string): string {
return signToken(`${userId}:${username}:${role}:${authSessionId}`);
}
/**
* Parse a verified auth token into user data. Returns null if invalid.
* Supports both old format (3 segments) and new format (4 segments).
*/
export function parseAuthToken(token: string): { id: string; username: string; role: string } | null {
export function parseAuthToken(token: string): { id: string; username: string; role: string; authSessionId: string } | null {
const payload = verifyToken(token);
if (!payload) return null;
const firstColon = payload.indexOf(':');
if (firstColon < 0) return null;
const secondColon = payload.indexOf(':', firstColon + 1);
if (secondColon < 0) return null;
const id = payload.slice(0, firstColon);
const username = payload.slice(firstColon + 1, secondColon);
const role = payload.slice(secondColon + 1);
const parts = payload.split(':');
// New format: userId:username:role:authSessionId (4 parts)
// Old format: userId:username:role (3 parts — legacy tokens before session tracking)
if (parts.length < 3) return null;
const id = parts[0];
const username = parts[1];
const role = parts[2];
const authSessionId = parts[3] ?? ''; // empty string for legacy tokens
if (!id || !username) return null;
return { id, username, role };
return { id, username, role, authSessionId };
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
@@ -85,8 +89,32 @@ export const handle: Handle = async ({ event, resolve }) => {
const user = parseAuthToken(authToken);
if (!user) {
log.warn('auth', 'auth cookie present but failed to parse (malformed or tampered)');
event.locals.user = null;
} else {
// Validate session against DB (only for new-format tokens with authSessionId)
let sessionValid = true;
if (user.authSessionId) {
try {
const revoked = await isSessionRevoked(user.authSessionId);
if (revoked) {
log.info('auth', 'auth cookie references revoked session', {
userId: user.id,
authSessionId: user.authSessionId
});
sessionValid = false;
// Clear the invalid cookie
event.cookies.delete(AUTH_COOKIE, { path: '/' });
} else {
// Best-effort: update last_seen in the background
touchUserSession(user.authSessionId).catch(() => {});
}
} catch (err) {
// DB error — fail open to avoid locking everyone out
log.warn('auth', 'session check failed (fail open)', { err: String(err) });
}
}
event.locals.user = sessionValid ? user : null;
}
event.locals.user = user;
} else {
event.locals.user = null;
}

View File

@@ -331,6 +331,51 @@
return data.url;
}
type AudioStatusResponse =
| { status: 'done'; url: string; filename: string }
| { status: 'pending' | 'generating'; job_id: string }
| { status: 'idle' }
| { status: 'failed'; error?: string };
/**
* Poll GET /api/audio/status/[slug]/[n]?voice=... every `intervalMs` ms
* until status is "done" or "failed" (or the caller cancels via signal).
*
* Returns the final status response, or throws on network error / cancellation.
*/
async function pollAudioStatus(
targetSlug: string,
targetChapter: number,
targetVoice: string,
intervalMs = 2000,
signal?: AbortSignal
): Promise<AudioStatusResponse> {
const qs = new URLSearchParams();
if (targetVoice) qs.set('voice', targetVoice);
const url = `/api/audio/status/${targetSlug}/${targetChapter}?${qs.toString()}`;
while (true) {
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`Status poll HTTP ${res.status}`);
const data = (await res.json()) as AudioStatusResponse;
if (data.status === 'done' || data.status === 'failed') {
return data;
}
// Still pending/generating — wait then retry.
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, intervalMs);
signal?.addEventListener('abort', () => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
});
});
}
}
// ── Pre-fetch next chapter ─────────────────────────────────────────────────
async function prefetchNext() {
@@ -354,7 +399,7 @@
return;
}
// Slow path: trigger Kokoro generation in background
// Slow path: trigger Kokoro generation (non-blocking POST), then poll.
const res = await fetch(`/api/audio/${slug}/${nextChapter}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -362,13 +407,31 @@
});
if (!res.ok) throw new Error(`Prefetch generation failed: HTTP ${res.status}`);
// If the scraper returned cached audio immediately (200), use the url.
if (res.status === 200) {
const cached = (await res.json()) as { url: string };
stopNextProgress();
audioStore.nextProgress = 100;
audioStore.nextAudioUrl = cached.url;
audioStore.nextStatus = 'prefetched';
return;
}
// 202: poll until done.
const final = await pollAudioStatus(slug, nextChapter, voice);
stopNextProgress();
audioStore.nextProgress = 100;
const url2 = await tryPresign(slug, nextChapter, voice);
if (!url2) throw new Error('Prefetch: audio generated but presign returned 404');
if (final.status === 'failed') {
throw new Error(`Prefetch failed: ${(final as { error?: string }).error ?? 'unknown'}`);
}
audioStore.nextAudioUrl = url2;
// Use the URL from the status response, or fall back to presign.
const doneUrl =
(final as { url?: string }).url ?? (await tryPresign(slug, nextChapter, voice));
if (!doneUrl) throw new Error('Prefetch: audio done but no URL available');
audioStore.nextAudioUrl = doneUrl;
audioStore.nextStatus = 'prefetched';
} catch {
stopNextProgress();
@@ -447,7 +510,7 @@
return;
}
// Slow path: trigger Kokoro generation.
// Slow path: trigger Kokoro generation (non-blocking POST), then poll.
audioStore.status = 'generating';
startProgress();
@@ -458,11 +521,32 @@
});
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
// If the scraper returned cached audio immediately (200), use the url.
if (res.status === 200) {
const cached = (await res.json()) as { url: string };
await finishProgress();
audioStore.audioUrl = cached.url;
audioStore.status = 'ready';
maybeStartPrefetch();
return;
}
// 202: poll until done.
const final = await pollAudioStatus(slug, chapter, voice);
if (final.status === 'failed') {
throw new Error(
`Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}`
);
}
await finishProgress();
const url2 = await tryPresign(slug, chapter, voice);
if (!url2) throw new Error('Audio generated but presign returned 404');
audioStore.audioUrl = url2;
// Use the URL from the status response, or fall back to presign.
const doneUrl =
(final as { url?: string }).url ?? (await tryPresign(slug, chapter, voice));
if (!doneUrl) throw new Error('Audio generated but no URL available');
audioStore.audioUrl = doneUrl;
audioStore.status = 'ready';
// Don't restore time for freshly generated audio — position is 0
// Immediately start pre-generating the next chapter in background.

View File

@@ -15,6 +15,46 @@ const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
// In docker-compose this would differ from the internal endpoint.
const MINIO_PUBLIC_URL = pubEnv.PUBLIC_MINIO_PUBLIC_URL ?? 'http://localhost:9000';
// ─── Avatar helpers ───────────────────────────────────────────────────────────
function extFromMime(mime: string): string {
if (mime.includes('png')) return 'png';
if (mime.includes('webp')) return 'webp';
if (mime.includes('gif')) return 'gif';
return 'jpg';
}
/**
* Returns a short-lived presigned PUT URL for uploading an avatar directly to MinIO,
* along with the object key to record in PocketBase after upload completes.
* Routed through the Go scraper which holds MinIO credentials.
*/
export async function presignAvatarUploadUrl(userId: string, mimeType: string): Promise<{ uploadUrl: string; key: string }> {
const ext = extFromMime(mimeType);
const res = await fetch(`${SCRAPER_URL}/api/presign/avatar-upload/${encodeURIComponent(userId)}?ext=${ext}`);
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`presign avatar upload failed: ${res.status} ${body}`);
}
const data = (await res.json()) as { upload_url: string; key: string };
return { uploadUrl: data.upload_url, key: data.key };
}
/**
* Returns a presigned GET URL for a user's avatar, rewritten to the public URL.
* Returns null if no avatar exists.
*/
export async function presignAvatarUrl(userId: string): Promise<string | null> {
const res = await fetch(`${SCRAPER_URL}/api/presign/avatar/${encodeURIComponent(userId)}`);
if (res.status === 404) return null;
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`presign avatar failed: ${res.status} ${body}`);
}
const data = (await res.json()) as { url: string };
return data.url ?? null;
}
/**
* Rewrites the MinIO host in a presigned URL to the public-facing URL.
* The presigned URL is signed against the internal endpoint (e.g. minio:9000),

View File

@@ -62,6 +62,7 @@ export interface User {
password_hash: string;
role: string;
created: string;
avatar_url?: string;
}
// ─── Auth token cache ─────────────────────────────────────────────────────────
@@ -657,6 +658,24 @@ export async function listScrapingTasks(): Promise<ScrapingTask[]> {
return listAll<ScrapingTask>('scraping_tasks', '', '-started');
}
// ─── Audio jobs ───────────────────────────────────────────────────────────────
export interface AudioJob {
id: string;
cache_key: string; // "slug/chapter/voice"
slug: string;
chapter: number;
voice: string;
status: string; // "pending" | "generating" | "done" | "failed"
error_message: string;
started: string;
finished: string;
}
export async function listAudioJobs(): Promise<AudioJob[]> {
return listAll<AudioJob>('audio_jobs', '', '-started');
}
export async function getAudioTime(
sessionId: string,
slug: string,
@@ -667,3 +686,128 @@ export async function getAudioTime(
if (!row || !row.audio_time) return null;
return row.audio_time;
}
// ─── User sessions ────────────────────────────────────────────────────────────
export interface UserSession {
id: string;
user_id: string;
session_id: string; // the auth session ID embedded in the token
user_agent: string;
ip: string;
created_at: string;
last_seen: string;
}
/**
* Create a new session record on login. Returns the record ID.
*/
export async function createUserSession(
userId: string,
authSessionId: string,
userAgent: string,
ip: string
): Promise<string> {
const now = new Date().toISOString();
const res = await pbPost('/api/collections/user_sessions/records', {
user_id: userId,
session_id: authSessionId,
user_agent: userAgent,
ip,
created_at: now,
last_seen: now
});
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('pocketbase', 'createUserSession POST failed', { userId, status: res.status, body });
throw new Error(`Failed to create session: ${res.status}`);
}
const rec = (await res.json()) as { id: string };
return rec.id;
}
/**
* Update last_seen on a session (best-effort, non-fatal if it fails).
*/
export async function touchUserSession(authSessionId: string): Promise<void> {
const row = await listOne<UserSession & { id: string }>(
'user_sessions',
`session_id="${authSessionId}"`
);
if (!row) return;
const token = await getToken();
await fetch(`${PB_URL}/api/collections/user_sessions/records/${row.id}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ last_seen: new Date().toISOString() })
});
}
/**
* Check whether a session has been revoked (i.e., not present in DB).
* Returns true if revoked/missing, false if valid.
*/
export async function isSessionRevoked(authSessionId: string): Promise<boolean> {
const row = await listOne<UserSession>('user_sessions', `session_id="${authSessionId}"`);
return row === null;
}
/**
* List all active sessions for a user.
*/
export async function listUserSessions(userId: string): Promise<UserSession[]> {
return listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
}
/**
* Revoke (delete) a specific session by its PocketBase record ID.
* Only allows deletion if the session belongs to the given userId.
*/
export async function revokeUserSession(recordId: string, userId: string): Promise<boolean> {
// Verify ownership before deleting
const token = await getToken();
const res = await fetch(`${PB_URL}/api/collections/user_sessions/records/${recordId}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!res.ok) return false;
const rec = (await res.json()) as UserSession;
if (rec.user_id !== userId) return false;
const del = await fetch(`${PB_URL}/api/collections/user_sessions/records/${recordId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
});
return del.ok || del.status === 204;
}
/**
* Revoke all sessions for a user (used on password change etc).
*/
export async function revokeAllUserSessions(userId: string): Promise<void> {
const sessions = await listUserSessions(userId);
const token = await getToken();
await Promise.all(
sessions.map((s) =>
fetch(`${PB_URL}/api/collections/user_sessions/records/${s.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
}).catch(() => {})
)
);
}
/**
* Update the avatar_url field for a user record.
*/
export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Promise<void> {
const token = await getToken();
const res = await fetch(`${PB_URL}/api/collections/app_users/records/${userId}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ avatar_url: avatarUrl })
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`updateUserAvatarUrl failed: ${res.status} ${body}`);
}
}

View File

@@ -211,10 +211,16 @@
{/if}
<header class="border-b border-zinc-700 bg-zinc-900 sticky top-0 z-50">
<nav class="max-w-6xl mx-auto px-4 h-14 flex items-center gap-6">
<a href="/" class="text-amber-400 font-bold text-lg tracking-tight hover:text-amber-300">
<a href="/" class="text-amber-400 font-bold text-lg tracking-tight hover:text-amber-300 shrink-0">
libnovel
</a>
{#if page.data.book?.title && /\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
<span class="text-zinc-400 text-sm truncate min-w-0 flex-1 sm:flex-none sm:max-w-xs">
{page.data.book.title}
</span>
{/if}
{#if data.user}
<!-- Desktop nav links (hidden on mobile) -->
<a
@@ -241,10 +247,16 @@
</a>
<a
href="/admin/audio"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/admin/audio') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
class="hidden sm:block text-sm transition-colors {page.url.pathname === '/admin/audio' ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
>
Audio cache
</a>
<a
href="/admin/audio-jobs"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/admin/audio-jobs') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
>
Audio jobs
</a>
{/if}
<a
href="/profile"
@@ -327,10 +339,17 @@
<a
href="/admin/audio"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/admin/audio') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname === '/admin/audio' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
>
Audio cache
</a>
<a
href="/admin/audio-jobs"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/admin/audio-jobs') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
>
Audio jobs
</a>
{/if}
<div class="my-1 border-t border-zinc-700/60"></div>
<form method="POST" action="/logout">
@@ -352,9 +371,9 @@
</main>
<footer class="border-t border-zinc-800 mt-auto">
<div class="max-w-6xl mx-auto px-4 py-5 flex flex-col sm:flex-row items-center justify-between gap-3">
<span class="text-zinc-500 text-sm font-semibold tracking-tight">libnovel</span>
<nav class="flex items-center gap-5 text-xs text-zinc-600">
<div class="max-w-6xl mx-auto px-4 py-6 flex flex-col items-center gap-4 text-xs text-zinc-600">
<!-- Top row: site links -->
<nav class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2">
<a href="/books" class="hover:text-zinc-400 transition-colors">Library</a>
<a href="/browse" class="hover:text-zinc-400 transition-colors">Discover</a>
<a
@@ -370,6 +389,13 @@
</svg>
</a>
</nav>
<!-- Bottom row: legal links + copyright -->
<div class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-zinc-700">
<a href="/disclaimer" class="hover:text-zinc-500 transition-colors">Disclaimer</a>
<a href="/privacy" class="hover:text-zinc-500 transition-colors">Privacy</a>
<a href="/dmca" class="hover:text-zinc-500 transition-colors">DMCA</a>
<span>&copy; {new Date().getFullYear()} libnovel</span>
</div>
</div>
</footer>
</div>
@@ -380,7 +406,7 @@
<!-- Chapter list drawer (slides up above the mini-bar) -->
{#if chapterDrawerOpen && audioStore.chapters.length > 0}
<div class="border-b border-zinc-700 bg-zinc-900 max-h-64 overflow-y-auto">
<div class="border-b border-zinc-700 bg-zinc-900 max-h-[32rem] overflow-y-auto">
<div class="max-w-6xl mx-auto px-4">
<div class="flex items-center justify-between py-2 border-b border-zinc-800 sticky top-0 bg-zinc-900">
<span class="text-xs font-semibold text-zinc-400 uppercase tracking-wider">Chapters</span>

View File

@@ -0,0 +1,17 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { listAudioJobs } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
export const load: PageServerLoad = async ({ locals }) => {
if (locals.user?.role !== 'admin') {
redirect(302, '/');
}
const jobs = await listAudioJobs().catch((e) => {
log.warn('admin/audio-jobs', 'failed to load audio jobs', { err: String(e) });
return [];
});
return { jobs };
};

View File

@@ -0,0 +1,152 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let jobs = $state(data.jobs);
// ── Live-poll: refresh while any job is in-flight ────────────────────────────
let hasInFlight = $derived(jobs.some((j) => j.status === 'pending' || j.status === 'generating'));
$effect(() => {
if (!hasInFlight) return;
const id = setInterval(async () => {
const res = await fetch('/admin/audio-jobs?__data=1').catch(() => null);
if (res?.ok) {
// SvelteKit invalidateAll is cleaner — just trigger a soft navigation reload.
import('$app/navigation').then(({ invalidateAll }) => invalidateAll());
}
}, 3000);
return () => clearInterval(id);
});
// Keep local state in sync when server re-loads
$effect(() => {
jobs = data.jobs;
});
// ── Helpers ──────────────────────────────────────────────────────────────────
function statusColor(status: string) {
if (status === 'done') return 'text-green-400';
if (status === 'generating') return 'text-amber-400 animate-pulse';
if (status === 'pending') return 'text-sky-400 animate-pulse';
if (status === 'failed') return 'text-red-400';
return 'text-zinc-300';
}
function fmtDate(s: string) {
if (!s) return '—';
return new Date(s).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
});
}
function duration(started: string, finished: string) {
if (!started || !finished) return '—';
const ms = new Date(finished).getTime() - new Date(started).getTime();
if (ms < 0) return '—';
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
return `${m}m ${s % 60}s`;
}
// ── Search ───────────────────────────────────────────────────────────────────
let q = $state('');
let filtered = $derived(
q.trim()
? jobs.filter(
(j) =>
j.slug.toLowerCase().includes(q.toLowerCase().trim()) ||
j.voice.toLowerCase().includes(q.toLowerCase().trim()) ||
j.status.toLowerCase().includes(q.toLowerCase().trim())
)
: jobs
);
// ── Stats ────────────────────────────────────────────────────────────────────
let stats = $derived({
total: jobs.length,
done: jobs.filter((j) => j.status === 'done').length,
failed: jobs.filter((j) => j.status === 'failed').length,
inFlight: jobs.filter((j) => j.status === 'pending' || j.status === 'generating').length
});
</script>
<svelte:head>
<title>Audio jobs — libnovel admin</title>
</svelte:head>
<div class="space-y-6">
<div class="flex items-start justify-between flex-wrap gap-3">
<div>
<h1 class="text-2xl font-bold text-zinc-100">Audio jobs</h1>
<p class="text-zinc-400 text-sm mt-1">
{stats.total} total &middot;
<span class="text-green-400">{stats.done} done</span> &middot;
{#if stats.failed > 0}
<span class="text-red-400">{stats.failed} failed</span> &middot;
{/if}
{#if stats.inFlight > 0}
<span class="text-amber-400 animate-pulse">{stats.inFlight} in-flight</span>
{:else}
<span class="text-zinc-500">0 in-flight</span>
{/if}
</p>
</div>
</div>
<!-- Search -->
<input
type="search"
bind:value={q}
placeholder="Filter by slug, voice or status…"
class="w-full max-w-sm bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
/>
{#if filtered.length === 0}
<p class="text-zinc-500 text-sm py-8 text-center">
{q.trim() ? 'No results.' : 'No audio jobs yet.'}
</p>
{:else}
<div class="overflow-x-auto rounded-xl border border-zinc-700">
<table class="w-full text-sm">
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
<tr>
<th class="px-4 py-3 text-left">Book</th>
<th class="px-4 py-3 text-right">Ch.</th>
<th class="px-4 py-3 text-left">Voice</th>
<th class="px-4 py-3 text-left">Status</th>
<th class="px-4 py-3 text-left">Started</th>
<th class="px-4 py-3 text-left">Duration</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-700/50">
{#each filtered as job}
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
<td class="px-4 py-3 text-zinc-200 font-medium">
<a href="/books/{job.slug}" class="hover:text-amber-400 transition-colors">
{job.slug}
</a>
</td>
<td class="px-4 py-3 text-right text-zinc-400">{job.chapter}</td>
<td class="px-4 py-3 text-zinc-400 font-mono text-xs">{job.voice}</td>
<td class="px-4 py-3">
<span class="font-medium {statusColor(job.status)}">{job.status}</span>
</td>
<td class="px-4 py-3 text-zinc-400">{fmtDate(job.started)}</td>
<td class="px-4 py-3 text-zinc-400">{duration(job.started, job.finished)}</td>
</tr>
{#if job.error_message}
<tr class="bg-red-950/20">
<td colspan="6" class="px-4 py-2 text-xs text-red-400 font-mono"
>{job.error_message}</td
>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
</div>

View File

@@ -11,8 +11,12 @@ const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
* Keeps the scraper URL server-side — the browser never needs to know it.
*
* Body: { voice?: string }
* Response: { url: string, filename: string }
* where `url` is a relative path to GET /api/audio/[slug]/[n]?voice=...
*
* Responses:
* 200 { url: string, filename: string } — audio already cached; url is a
* relative path to GET /api/audio/[slug]/[n]?voice=...
* 202 { job_id: string, status: "pending"|"generating" } — generation
* enqueued; poll GET /api/audio/status/[slug]/[n]?voice=... until done.
*/
export const POST: RequestHandler = async ({ params, request }) => {
const { slug, n } = params;
@@ -40,18 +44,28 @@ export const POST: RequestHandler = async ({ params, request }) => {
error(scraperRes.status as Parameters<typeof error>[0], text || 'Audio generation failed');
}
const data = (await scraperRes.json()) as { url: string; filename: string };
const data = (await scraperRes.json()) as
| { url: string; filename: string }
| { job_id: string; status: string };
// The scraper returns a proxy URL pointing to /api/audio-proxy/... — we rewrite
// it to our own /api/audio/[slug]/[n]?... so the browser never calls the scraper directly.
const voice = body.voice ?? '';
const qs = new URLSearchParams();
if (voice) qs.set('voice', voice);
// 202 Accepted: generation enqueued — return job_id + status for polling.
if (scraperRes.status === 202 || 'job_id' in data) {
return new Response(JSON.stringify(data), {
status: 202,
headers: { 'Content-Type': 'application/json' }
});
}
// 200: audio was already cached — rewrite the proxy URL through our own handler.
const cached = data as { url: string; filename: string };
return new Response(
JSON.stringify({
url: `/api/audio/${slug}/${chapter}?${qs.toString()}`,
filename: data.filename
filename: cached.filename
}),
{ headers: { 'Content-Type': 'application/json' } }
);

View File

@@ -0,0 +1,67 @@
import { 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/audio/status/[slug]/[n]?voice=...
* Proxies the audio generation status check to the scraper's
* GET /api/audio/status/{slug}/{n} endpoint.
*
* Possible responses from scraper (passed through as-is):
* {"status":"done","url":"/api/audio-proxy/...","filename":"..."}
* {"status":"pending"|"generating","job_id":"..."}
* {"status":"idle"}
* {"status":"failed","error":"..."}
*
* When status is "done" the scraper returns a proxy URL pointing to its own
* /api/audio-proxy/... — we rewrite this to our own
* /api/audio/[slug]/[n]?voice=... so the browser never calls the scraper.
*/
export const GET: RequestHandler = async ({ params, url }) => {
const { slug, n } = params;
const chapter = parseInt(n, 10);
if (!slug || !chapter || chapter < 1) {
error(400, 'Invalid slug or chapter number');
}
const voice = url.searchParams.get('voice') ?? '';
const qs = new URLSearchParams();
if (voice) qs.set('voice', voice);
const scraperRes = await fetch(
`${SCRAPER_URL}/api/audio/status/${slug}/${chapter}?${qs.toString()}`
);
if (!scraperRes.ok) {
const text = await scraperRes.text().catch(() => '');
log.error('audio', 'scraper audio status check failed', {
slug,
chapter,
status: scraperRes.status,
body: text
});
error(scraperRes.status as Parameters<typeof error>[0], text || 'Status check failed');
}
const data = (await scraperRes.json()) as {
status: string;
job_id?: string;
url?: string;
filename?: string;
error?: string;
};
// Rewrite the proxy URL if the audio is done so it routes through us.
if (data.status === 'done' && data.url) {
const rewrittenQs = new URLSearchParams();
if (voice) rewrittenQs.set('voice', voice);
data.url = `/api/audio/${slug}/${chapter}?${rewrittenQs.toString()}`;
}
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
};

View File

@@ -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 });
};

View File

@@ -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' }
});
};

View File

@@ -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 });
};

View File

@@ -0,0 +1,22 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getUserByUsername } from '$lib/server/pocketbase';
/**
* 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');
}
// Fetch full record from PocketBase to get avatar_url
const record = await getUserByUsername(locals.user.username).catch(() => null);
return json({
id: locals.user.id,
username: locals.user.username,
role: locals.user.role,
avatar_url: record?.avatar_url ?? null
});
};

View File

@@ -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' }
});
};

View File

@@ -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`);
}
};

View File

@@ -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
? '<p>' + chapterData.text.replace(/\n{2,}/g, '</p><p>').replace(/\n/g, '<br>') + '</p>'
: '';
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
});
};

View File

@@ -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<string, Book>(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
}
});
};

View File

@@ -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<ReturnType<typeof listBooks>>;
let progressList: Awaited<ReturnType<typeof allProgress>>;
let savedSlugs: Set<string>;
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<string, number> = {};
const progressUpdatedMap: Record<string, string> = {};
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);
};

View File

@@ -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) {

View File

@@ -0,0 +1,81 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { presignAvatarUploadUrl, presignAvatarUrl } from '$lib/server/minio';
import { updateUserAvatarUrl, getUserByUsername } from '$lib/server/pocketbase';
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
/**
* POST /api/profile/avatar
* Body: JSON { mime_type: "image/jpeg" | "image/png" | "image/webp" }
*
* Returns a short-lived presigned PUT URL pointing at MinIO (public endpoint)
* so the client can upload the image bytes directly, bypassing the server.
* After the PUT completes, the client must call PATCH /api/profile/avatar
* with the returned key to record it in PocketBase.
*
* Returns: { upload_url: string, key: string }
*/
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) error(401, 'Not authenticated');
let mimeType = 'image/jpeg';
try {
const body = await request.json();
if (body?.mime_type) mimeType = body.mime_type;
} catch {
// default to jpeg if body is missing/invalid
}
if (!ALLOWED_TYPES.includes(mimeType)) {
error(400, `Unsupported image type: ${mimeType}. Allowed: jpeg, png, webp`);
}
const { uploadUrl, key } = await presignAvatarUploadUrl(locals.user.id, mimeType);
return json({ upload_url: uploadUrl, key });
};
/**
* PATCH /api/profile/avatar
* Body: JSON { key: string }
*
* Called after the client has successfully PUT the image to MinIO via the
* presigned URL. Records the object key in PocketBase and returns a fresh
* presigned GET URL for immediate display.
*
* Returns: { avatar_url: string | null }
*/
export const PATCH: RequestHandler = async ({ request, locals }) => {
if (!locals.user) error(401, 'Not authenticated');
let key: string | undefined;
try {
const body = await request.json();
if (typeof body?.key === 'string') key = body.key;
} catch {
error(400, 'Invalid JSON body');
}
if (!key) error(400, 'Missing "key" field');
await updateUserAvatarUrl(locals.user.id, key);
const avatarUrl = await presignAvatarUrl(locals.user.id);
return json({ avatar_url: avatarUrl });
};
/**
* GET /api/profile/avatar
* Returns a presigned GET URL for the current user's avatar, or null if none set.
*/
export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user) error(401, 'Not authenticated');
const record = await getUserByUsername(locals.user.username).catch(() => null);
if (!record?.avatar_url) {
return json({ avatar_url: null });
}
const avatarUrl = await presignAvatarUrl(locals.user.id);
return json({ avatar_url: avatarUrl });
};

View File

@@ -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 });
};

View File

@@ -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');
}
};

View File

@@ -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=<query>
* 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');
}
};

View File

@@ -0,0 +1,32 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { listUserSessions } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* GET /api/sessions
* Returns all active sessions for the logged-in user.
*/
export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user) {
error(401, 'Not logged in');
}
try {
const sessions = await listUserSessions(locals.user.id);
// Don't expose raw session_id to the client — only the record ID for revocation
const safe = sessions.map((s) => ({
id: s.id,
user_agent: s.user_agent,
ip: s.ip,
created_at: s.created_at,
last_seen: s.last_seen,
// Tell the client whether this is the currently active session
is_current: s.session_id === locals.user!.authSessionId
}));
return json({ sessions: safe });
} catch (e) {
log.error('sessions', 'GET failed', { err: String(e) });
error(500, 'Failed to load sessions');
}
};

View File

@@ -0,0 +1,41 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { revokeUserSession } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* DELETE /api/sessions/[id]
* Revokes a specific session by its PocketBase record ID.
* Only the owner can revoke their own sessions.
*/
export const DELETE: RequestHandler = async ({ params, locals, cookies }) => {
if (!locals.user) {
error(401, 'Not logged in');
}
const recordId = params.id;
if (!recordId) {
error(400, 'Session ID required');
}
try {
const ok = await revokeUserSession(recordId, locals.user.id);
if (!ok) {
error(404, 'Session not found or not yours');
}
// If the user is terminating their own current session, clear their auth cookie
// so they get logged out immediately (the hook would do this on the next request anyway,
// but clearing it here gives instant feedback for the "end this session" flow).
// For other sessions, we leave the cookie intact.
// We detect "current session" via authSessionId — but since the client sends the
// record ID (not the session_id), we rely on the UI to redirect after ending its own session.
log.info('sessions', 'session revoked', { recordId, userId: locals.user.id });
return json({ ok: true });
} catch (e) {
if (e instanceof Error && 'status' in e) throw e; // re-throw SvelteKit errors
log.error('sessions', 'DELETE failed', { recordId, err: String(e) });
error(500, 'Failed to revoke session');
}
};

View File

@@ -32,15 +32,23 @@
const genres = $derived(parseGenres(data.book.genres));
// Paginate chapter list — show 100 at a time
const PAGE_SIZE = 100;
// Paginate chapter list — 50 on mobile, 100 on sm+ (≥640px)
let pageSize = $state(50);
onMount(() => {
const mq = window.matchMedia('(min-width: 640px)');
pageSize = mq.matches ? 100 : 50;
const handler = (e: MediaQueryListEvent) => { pageSize = e.matches ? 100 : 50; };
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
});
// Start on the page that contains the current chapter (if any)
function pageForChapter(chapterNum: number | null, list: typeof chapterList): number {
if (!chapterNum || list.length === 0) return 0;
const idx = list.findIndex((c) => c.number === chapterNum);
if (idx === -1) return 0;
return Math.floor(idx / PAGE_SIZE);
return Math.floor(idx / pageSize);
}
let page = $state(pageForChapter(data.lastChapter, data.inLib ? data.chapters : (data.previewChapters ?? [])));
@@ -51,9 +59,9 @@
? data.chapters
: (data.previewChapters ?? [])
);
const totalPages = $derived(Math.ceil(chapterList.length / PAGE_SIZE));
const totalPages = $derived(Math.ceil(chapterList.length / pageSize));
const visibleChapters = $derived(
chapterList.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
chapterList.slice(page * pageSize, (page + 1) * pageSize)
);
// ── Chapter list polling ──────────────────────────────────────────────────
@@ -148,54 +156,146 @@
rangeScraping = false;
}
}
// ── Summary expand/collapse ───────────────────────────────────────────────
let summaryExpanded = $state(false);
// ── Admin panel expand/collapse ───────────────────────────────────────────
let adminOpen = $state(false);
</script>
<svelte:head>
<title>{data.book.title} — libnovel</title>
</svelte:head>
<!-- Book header -->
<div class="flex gap-6 mb-8">
<!-- ═══════════════════════════════════════════════════════════════ Hero ══ -->
<div class="relative rounded-xl overflow-hidden mb-8">
<!-- Blurred cover background -->
{#if data.book.cover}
<img
src={data.book.cover}
alt={data.book.title}
class="w-32 sm:w-40 rounded-lg object-cover flex-shrink-0 border border-zinc-700"
/>
<div
class="absolute inset-0 bg-cover bg-center scale-110"
style="background-image: url('{data.book.cover}'); filter: blur(24px); opacity: 0.18;"
aria-hidden="true"
></div>
{/if}
<div class="absolute inset-0 bg-gradient-to-b from-zinc-900/60 to-zinc-900/95 pointer-events-none" aria-hidden="true"></div>
<div class="flex flex-col gap-2 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<h1 class="text-2xl font-bold text-zinc-100 leading-tight">{data.book.title}</h1>
{#if !data.inLib}
<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-700 text-zinc-400 border border-zinc-600 shrink-0" title="This book was fetched live from the source and is not yet in your library">
not in library
</span>
<div class="relative flex flex-col p-5 sm:p-7 gap-4">
<!-- Cover + meta row -->
<div class="flex gap-5 sm:gap-8">
<!-- Cover image -->
{#if data.book.cover}
<img
src={data.book.cover}
alt={data.book.title}
class="w-28 sm:w-48 rounded-lg object-cover flex-shrink-0 border border-zinc-700 shadow-xl self-start"
/>
{/if}
<!-- Meta -->
<div class="flex flex-col gap-2 min-w-0 flex-1">
<!-- Title + "not in library" badge -->
<div class="flex items-start gap-2 flex-wrap">
<h1 class="text-xl sm:text-3xl font-bold text-zinc-100 leading-tight">{data.book.title}</h1>
{#if !data.inLib}
<span
class="mt-1 text-xs px-2 py-0.5 rounded-full bg-zinc-700 text-zinc-400 border border-zinc-600 shrink-0"
title="This book was fetched live from the source and is not yet in your library"
>
not in library
</span>
{/if}
</div>
<!-- Author -->
{#if data.book.author}
<p class="text-zinc-400 text-sm">{data.book.author}</p>
{/if}
<!-- Status + genres -->
<div class="flex flex-wrap gap-1.5 mt-0.5">
{#if data.book.status}
<span class="text-xs px-2 py-0.5 rounded bg-zinc-700 text-zinc-300 border border-zinc-600">{data.book.status}</span>
{/if}
{#each genres as genre}
<span class="text-xs px-2 py-0.5 rounded bg-zinc-800 text-zinc-400 border border-zinc-700">{genre}</span>
{/each}
</div>
<!-- Summary with expand toggle -->
{#if data.book.summary}
<div class="mt-1">
<p class="text-zinc-400 text-sm leading-relaxed break-words {summaryExpanded ? '' : 'line-clamp-3'}">
{data.book.summary}
</p>
{#if data.book.summary.length > 220}
<button
onclick={() => (summaryExpanded = !summaryExpanded)}
class="text-xs text-amber-400/70 hover:text-amber-400 mt-1 transition-colors"
>
{summaryExpanded ? 'Less' : 'More'}
</button>
{/if}
</div>
{/if}
<!-- CTA buttons — desktop only (hidden on mobile, shown below on mobile) -->
<div class="hidden sm:flex gap-2 mt-3 items-center flex-wrap">
{#if data.lastChapter}
<a
href="/books/{data.book.slug}/chapters/{data.lastChapter}"
class="px-5 py-2 bg-amber-400 text-zinc-900 font-semibold rounded-lg text-sm hover:bg-amber-300 transition-colors shadow"
>
Continue ch.{data.lastChapter}
</a>
{/if}
{#if chapterList.length > 0}
<a
href="/books/{data.book.slug}/chapters/1"
class="px-4 py-2 rounded-lg text-sm font-semibold transition-colors
{data.lastChapter
? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600'
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300 shadow'}"
>
{data.inLib ? 'Start from ch.1' : 'Preview ch.1'}
</a>
{/if}
{#if data.inLib}
<button
onclick={toggleSave}
disabled={saving}
title={saved ? 'Remove from library' : 'Add to library'}
class="flex items-center justify-center w-9 h-9 rounded-lg border transition-colors disabled:opacity-50
{saved
? 'bg-amber-400/20 text-amber-300 border-amber-400/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
: 'bg-zinc-700 text-zinc-400 border-zinc-600 hover:bg-zinc-600 hover:text-zinc-100'}"
>
{#if saving}
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{:else if saved}
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
{/if}
</button>
{/if}
</div>
</div>
</div>
{#if data.book.author}
<p class="text-zinc-400 text-sm">{data.book.author}</p>
{/if}
<div class="flex flex-wrap gap-2 mt-1">
{#if data.book.status}
<span class="text-xs px-2 py-1 rounded bg-zinc-700 text-zinc-300">{data.book.status}</span>
{/if}
{#each genres as genre}
<span class="text-xs px-2 py-1 rounded bg-zinc-800 text-zinc-400">{genre}</span>
{/each}
</div>
{#if data.book.summary}
<p class="text-zinc-400 text-sm leading-relaxed line-clamp-4 mt-1">{data.book.summary}</p>
{/if}
<div class="flex gap-3 mt-2 flex-wrap items-center">
<!-- CTA buttons — mobile only, full-width row below cover+meta -->
<div class="flex sm:hidden gap-2 items-center">
{#if data.lastChapter}
<a
href="/books/{data.book.slug}/chapters/{data.lastChapter}"
class="px-4 py-2 bg-amber-400 text-zinc-900 font-semibold rounded text-sm hover:bg-amber-300 transition-colors"
class="flex-1 text-center px-4 py-2.5 bg-amber-400 text-zinc-900 font-semibold rounded-lg text-sm hover:bg-amber-300 transition-colors shadow"
>
Continue ch.{data.lastChapter}
</a>
@@ -203,21 +303,23 @@
{#if chapterList.length > 0}
<a
href="/books/{data.book.slug}/chapters/1"
class="px-4 py-2 bg-zinc-700 text-zinc-100 font-semibold rounded text-sm hover:bg-zinc-600 transition-colors"
class="flex-1 text-center px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors
{data.lastChapter
? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600'
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300 shadow'}"
>
{data.inLib ? 'Start from ch.1' : 'Preview ch.1'}
</a>
{/if}
<!-- Save / unsave button -->
{#if data.inLib}
<button
onclick={toggleSave}
disabled={saving}
title={saved ? 'Remove from library' : 'Add to library'}
class="flex items-center gap-1.5 px-3 py-2 rounded text-sm font-medium transition-colors disabled:opacity-50
class="flex items-center justify-center w-10 h-10 flex-shrink-0 rounded-lg border transition-colors disabled:opacity-50
{saved
? 'bg-amber-400/20 text-amber-300 hover:bg-red-500/20 hover:text-red-300 border border-amber-400/30 hover:border-red-400/30'
: 'bg-zinc-700 text-zinc-400 hover:text-zinc-100 hover:bg-zinc-600 border border-zinc-600'}"
? 'bg-amber-400/20 text-amber-300 border-amber-400/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
: 'bg-zinc-700 text-zinc-400 border-zinc-600 hover:bg-zinc-600 hover:text-zinc-100'}"
>
{#if saving}
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
@@ -233,126 +335,48 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
{/if}
{saved ? 'Saved' : 'Save'}
</button>
{/if}
</div>
</div>
</div>
<!-- Chapter list -->
<div class="mt-4">
<!-- ══════════════════════════════════════════════════ Chapter list ══ -->
<div>
<!-- Header row: title + pagination -->
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
<h2 class="text-lg font-semibold text-zinc-100">
<h2 class="text-base font-semibold text-zinc-200">
Chapters
<span class="text-zinc-500 font-normal text-sm ml-1">({chapterList.length})</span>
{#if chapterList.length > 0}
<span class="text-zinc-500 font-normal text-sm ml-1">({chapterList.length})</span>
{/if}
</h2>
<div class="flex items-center gap-3 flex-wrap">
{#if data.isAdmin && data.book.source_url}
{#if totalPages > 1}
<div class="flex gap-2 items-center text-sm">
<button
onclick={rescrape}
disabled={scraping}
class="px-3 py-1 rounded text-xs font-medium transition-colors flex items-center gap-1.5
{scraping
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
: 'bg-zinc-700 text-zinc-200 hover:bg-zinc-600'}"
title="Re-scrape this book from source"
onclick={() => (page = Math.max(0, page - 1))}
disabled={page === 0}
class="px-2 py-1 rounded bg-zinc-800 text-zinc-300 disabled:opacity-40 hover:bg-zinc-700 transition-colors"
>
{#if scraping}
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Queuing…
{:else}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Rescrape
{/if}
&larr;
</button>
{/if}
{#if totalPages > 1}
<div class="flex gap-2 items-center text-sm">
<button
onclick={() => (page = Math.max(0, page - 1))}
disabled={page === 0}
class="px-2 py-1 rounded bg-zinc-700 text-zinc-300 disabled:opacity-40 hover:bg-zinc-600 transition-colors"
>
&larr;
</button>
<span class="text-zinc-400">{page + 1} / {totalPages}</span>
<button
onclick={() => (page = Math.min(totalPages - 1, page + 1))}
disabled={page === totalPages - 1}
class="px-2 py-1 rounded bg-zinc-700 text-zinc-300 disabled:opacity-40 hover:bg-zinc-600 transition-colors"
>
&rarr;
</button>
</div>
{/if}
</div>
<span class="text-zinc-500 text-xs tabular-nums">
{page * pageSize + 1}{Math.min((page + 1) * pageSize, chapterList.length)} of {chapterList.length}
</span>
<button
onclick={() => (page = Math.min(totalPages - 1, page + 1))}
disabled={page === totalPages - 1}
class="px-2 py-1 rounded bg-zinc-800 text-zinc-300 disabled:opacity-40 hover:bg-zinc-700 transition-colors"
>
&rarr;
</button>
</div>
{/if}
</div>
{#if scrapeResult}
<div class="mb-3 px-3 py-2 rounded text-xs font-medium
{scrapeResult === 'queued' ? 'bg-green-900/40 text-green-300 border border-green-800' :
scrapeResult === 'busy' ? 'bg-amber-900/40 text-amber-300 border border-amber-800' :
'bg-red-900/40 text-red-300 border border-red-800'}">
{scrapeResult === 'queued' ? 'Rescrape queued — running in background.' :
scrapeResult === 'busy' ? 'Scraper is busy with another job. Try again shortly.' :
'Failed to queue rescrape. Check server logs.'}
</div>
{/if}
<!-- Admin: range scrape controls -->
{#if data.isAdmin && data.book.source_url}
<div class="mb-4 p-3 rounded bg-zinc-800/60 border border-zinc-700 flex flex-wrap items-end gap-3">
<div class="flex flex-col gap-1">
<label for="range-from" class="text-xs text-zinc-500">From chapter</label>
<input
id="range-from"
type="number"
min="1"
bind:value={rangeFrom}
placeholder="1"
class="w-24 px-2 py-1 rounded bg-zinc-700 border border-zinc-600 text-zinc-200 text-xs focus:outline-none focus:border-amber-400"
/>
</div>
<div class="flex flex-col gap-1">
<label for="range-to" class="text-xs text-zinc-500">To chapter (optional)</label>
<input
id="range-to"
type="number"
min="1"
bind:value={rangeTo}
placeholder="end"
class="w-24 px-2 py-1 rounded bg-zinc-700 border border-zinc-600 text-zinc-200 text-xs focus:outline-none focus:border-amber-400"
/>
</div>
<button
onclick={scrapeRange}
disabled={rangeScraping || !rangeFrom}
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
{rangeScraping || !rangeFrom
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
: 'bg-amber-500/20 text-amber-300 hover:bg-amber-500/40 border border-amber-500/30'}"
>
{rangeScraping ? 'Queuing…' : 'Scrape range'}
</button>
{#if rangeResult}
<span class="text-xs {rangeResult === 'queued' ? 'text-green-400' : rangeResult === 'busy' ? 'text-amber-400' : 'text-red-400'}">
{rangeResult === 'queued' ? 'Range scrape queued.' : rangeResult === 'busy' ? 'Scraper busy.' : 'Error queuing.'}
</span>
{/if}
</div>
{/if}
<!-- Chapter rows -->
{#if pollingChapters}
<!-- Chapter list is being indexed in the background -->
<div class="flex items-center gap-3 py-4 text-zinc-500 text-sm">
<svg class="w-4 h-4 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
@@ -360,7 +384,7 @@
</svg>
Indexing chapter list…
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-1 opacity-40 pointer-events-none">
<div class="flex flex-col gap-0.5 opacity-40 pointer-events-none">
{#each Array(8) as _}
<div class="h-9 rounded bg-zinc-800 animate-pulse"></div>
{/each}
@@ -368,35 +392,32 @@
{:else if chapterList.length === 0}
<p class="text-zinc-500 text-sm">No chapters available yet.</p>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-1">
<div class="flex flex-col gap-0.5">
{#each visibleChapters as chapter}
{@const isCurrent = data.lastChapter === chapter.number}
{@const chapterUrl = data.inLib
? `/books/${data.book.slug}/chapters/${chapter.number}`
: `/books/${data.book.slug}/chapters/${chapter.number}?preview=1&chapter_url=${encodeURIComponent((chapter as { url?: string }).url ?? '')}&title=${encodeURIComponent(chapter.title ?? '')}`}
<div class="flex items-center gap-3 px-3 py-2 rounded hover:bg-zinc-800 transition-colors group {isCurrent ? 'bg-zinc-800' : ''}">
<a
href={chapterUrl}
class="flex items-center gap-3 flex-1 min-w-0"
>
<span
class="text-xs font-mono w-10 text-right flex-shrink-0 {isCurrent
? 'text-amber-400'
: 'text-zinc-500'}"
>
<div class="flex items-center gap-2 px-3 py-2.5 rounded hover:bg-zinc-800/70 transition-colors group {isCurrent ? 'bg-zinc-800' : ''}">
<a href={chapterUrl} class="flex items-center gap-2 flex-1 min-w-0">
<!-- Chapter number -->
<span class="text-sm font-mono w-10 text-right flex-shrink-0 {isCurrent ? 'text-amber-400' : 'text-zinc-600'}">
{chapter.number}
</span>
<span class="text-sm text-zinc-300 group-hover:text-zinc-100 truncate flex-1">
<!-- Title -->
<span class="text-base {isCurrent ? 'text-amber-300' : 'text-zinc-300 group-hover:text-zinc-100'} truncate min-w-0 flex-1 transition-colors">
{chapter.title || `Chapter ${chapter.number}`}
</span>
{#if isCurrent}
<span class="text-xs text-amber-400 flex-shrink-0">reading</span>
{/if}
<!-- Date label — desktop only -->
{#if (chapter as { date_label?: string }).date_label}
<span class="text-xs text-zinc-600 flex-shrink-0 hidden sm:block">{(chapter as { date_label?: string }).date_label}</span>
<span class="text-sm text-zinc-600 flex-shrink-0 max-sm:hidden">&middot; {(chapter as { date_label?: string }).date_label}</span>
{/if}
<!-- "reading" badge -->
{#if isCurrent}
<span class="text-sm text-amber-500 flex-shrink-0 font-medium">reading</span>
{/if}
</a>
<!-- Admin: scrape from this chapter up -->
<!-- Admin: scrape from this chapter up (hover-only) -->
{#if data.isAdmin && data.book.source_url && data.inLib}
<button
onclick={() => scrapeFromChapter(chapter.number)}
@@ -411,4 +432,96 @@
{/each}
</div>
{/if}
<!-- ── Admin panel (collapsed by default) ── -->
{#if data.isAdmin && data.book.source_url}
<div class="mt-6 border border-zinc-800 rounded-lg overflow-hidden">
<button
onclick={() => (adminOpen = !adminOpen)}
class="w-full flex items-center gap-2 px-4 py-2.5 text-xs font-medium text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/50 transition-colors text-left"
>
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
Admin
<svg class="w-3 h-3 ml-auto transition-transform {adminOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
{#if adminOpen}
<div class="px-4 py-3 border-t border-zinc-800 flex flex-col gap-4">
<!-- Rescrape -->
<div class="flex items-center gap-3 flex-wrap">
<button
onclick={rescrape}
disabled={scraping}
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
{scraping ? 'bg-zinc-700 text-zinc-500 cursor-not-allowed' : 'bg-zinc-700 text-zinc-200 hover:bg-zinc-600'}"
>
{#if scraping}
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Queuing…
{:else}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Rescrape book
{/if}
</button>
{#if scrapeResult}
<span class="text-xs {scrapeResult === 'queued' ? 'text-green-400' : scrapeResult === 'busy' ? 'text-amber-400' : 'text-red-400'}">
{scrapeResult === 'queued' ? 'Queued.' : scrapeResult === 'busy' ? 'Scraper busy.' : 'Error.'}
</span>
{/if}
</div>
<!-- Range scrape -->
<div class="flex flex-wrap items-end gap-3">
<div class="flex flex-col gap-1">
<label for="range-from" class="text-xs text-zinc-500">From chapter</label>
<input
id="range-from"
type="number"
min="1"
bind:value={rangeFrom}
placeholder="1"
class="w-24 px-2 py-1 rounded bg-zinc-700 border border-zinc-600 text-zinc-200 text-xs focus:outline-none focus:border-amber-400"
/>
</div>
<div class="flex flex-col gap-1">
<label for="range-to" class="text-xs text-zinc-500">To chapter (optional)</label>
<input
id="range-to"
type="number"
min="1"
bind:value={rangeTo}
placeholder="end"
class="w-24 px-2 py-1 rounded bg-zinc-700 border border-zinc-600 text-zinc-200 text-xs focus:outline-none focus:border-amber-400"
/>
</div>
<button
onclick={scrapeRange}
disabled={rangeScraping || !rangeFrom}
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
{rangeScraping || !rangeFrom
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
: 'bg-amber-500/20 text-amber-300 hover:bg-amber-500/40 border border-amber-500/30'}"
>
{rangeScraping ? 'Queuing…' : 'Scrape range'}
</button>
{#if rangeResult}
<span class="text-xs {rangeResult === 'queued' ? 'text-green-400' : rangeResult === 'busy' ? 'text-amber-400' : 'text-red-400'}">
{rangeResult === 'queued' ? 'Range scrape queued.' : rangeResult === 'busy' ? 'Scraper busy.' : 'Error queuing.'}
</span>
{/if}
</div>
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -6,11 +6,19 @@
let { data }: { data: PageData } = $props();
// ── Live-fetch fallback when chapter text is missing in storage ───────────
let html = $state(data.html);
let fetchingContent = $state(!data.isPreview && !data.html);
let fetchError = $state('');
// ── Word count ────────────────────────────────────────────────────────────
function countWords(htmlStr: string | null): number {
if (!htmlStr) return 0;
// Strip HTML tags, collapse whitespace, split on whitespace
return htmlStr.replace(/<[^>]+>/g, ' ').trim().split(/\s+/).filter(Boolean).length;
}
const wordCount = $derived(countWords(html));
onMount(async () => {
// Record reading progress (skip for preview chapters)
if (!data.isPreview) {
@@ -60,7 +68,7 @@
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
{data.book.title}
Chapters
</a>
<div class="flex gap-2">
@@ -85,12 +93,11 @@
<!-- Chapter heading -->
<div class="mb-6">
<p class="text-zinc-500 text-sm mb-1">Chapter {data.chapter.number}</p>
<h1 class="text-xl font-bold text-zinc-100">
{data.chapter.title || `Chapter ${data.chapter.number}`}
</h1>
{#if data.chapter.date_label}
<p class="text-zinc-600 text-xs mt-1">{data.chapter.date_label}</p>
{#if wordCount > 0}
<p class="text-zinc-600 text-xs mt-1">{wordCount.toLocaleString()} words</p>
{/if}
</div>

View File

@@ -0,0 +1,34 @@
<svelte:head>
<title>Disclaimer — libnovel</title>
</svelte:head>
<div class="max-w-2xl mx-auto py-10 px-4">
<h1 class="text-2xl font-bold text-zinc-100 mb-6">Disclaimer</h1>
<div class="space-y-5 text-sm text-zinc-400 leading-relaxed">
<p>
libnovel is a personal reading tool that indexes and caches publicly accessible novel content
from third-party sources, primarily <a href="https://novelfire.net" target="_blank" rel="noopener noreferrer" class="text-amber-400 hover:text-amber-300 transition-colors">novelfire.net</a>.
It is not affiliated with, endorsed by, or in any way officially connected to those sources.
</p>
<p>
All novel titles, cover images, chapter text, and related materials are the property of their
respective authors and publishers. libnovel does not claim ownership of any of this content.
The content is reproduced solely for personal, non-commercial reading convenience.
</p>
<p>
If you are a rights holder and believe your work is being used without authorisation, please
refer to our <a href="/dmca" class="text-amber-400 hover:text-amber-300 transition-colors">DMCA policy</a>
for instructions on how to request removal.
</p>
<p>
libnovel makes no warranties regarding the accuracy, completeness, or timeliness of any
content displayed. Use of this site is at your own risk.
</p>
<p class="text-zinc-600 text-xs mt-8">Last updated: {new Date().getFullYear()}</p>
</div>
</div>

View File

@@ -0,0 +1,45 @@
<svelte:head>
<title>DMCA — libnovel</title>
</svelte:head>
<div class="max-w-2xl mx-auto py-10 px-4">
<h1 class="text-2xl font-bold text-zinc-100 mb-6">DMCA Takedown Policy</h1>
<div class="prose-zinc space-y-5 text-sm text-zinc-400 leading-relaxed">
<p>
libnovel respects the intellectual property rights of authors, publishers, and other content
creators. If you believe that content available through this site infringes your copyright,
please send a written takedown notice to the contact address below.
</p>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Your notice must include</h2>
<ol class="list-decimal list-inside space-y-2 pl-1">
<li>Your full legal name and contact information (email address).</li>
<li>A description of the copyrighted work you claim has been infringed.</li>
<li>The specific URL(s) on this site where the allegedly infringing content appears.</li>
<li>
A statement that you have a good-faith belief that the use is not authorised by the copyright
owner, its agent, or the law.
</li>
<li>
A statement, made under penalty of perjury, that the information in your notice is accurate
and that you are the copyright owner or authorised to act on their behalf.
</li>
<li>Your electronic or physical signature.</li>
</ol>
<h2 class="text-base font-semibold text-zinc-200 mt-6">How to submit</h2>
<p>
Send your notice by email to <span class="text-zinc-300 font-medium">dmca@libnovel.local</span>.
We will review valid notices and remove or disable access to the identified content promptly.
</p>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Counter-notices</h2>
<p>
If you believe content was removed in error, you may submit a counter-notice to the same
address with the information required under 17 U.S.C. § 512(g)(3).
</p>
<p class="text-zinc-600 text-xs mt-8">Last updated: {new Date().getFullYear()}</p>
</div>
</div>

View File

@@ -1,8 +1,9 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { loginUser, createUser, mergeSessionProgress } from '$lib/server/pocketbase';
import { loginUser, 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;
@@ -43,7 +44,20 @@ export const actions: Actions = {
log.warn('auth', 'login: mergeSessionProgress failed (non-fatal)', { err: String(err) })
);
const token = createAuthToken(user.id, user.username, user.role ?? 'user');
// Create a unique auth session ID for this login
const authSessionId = randomBytes(16).toString('hex');
// Record the session in PocketBase (best-effort, non-fatal)
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((err) =>
log.warn('auth', 'login: createUserSession failed (non-fatal)', { err: String(err) })
);
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
cookies.set(AUTH_COOKIE, token, {
path: '/',
httpOnly: true,
@@ -102,7 +116,20 @@ export const actions: Actions = {
log.warn('auth', 'register: mergeSessionProgress failed (non-fatal)', { err: String(err) })
);
const token = createAuthToken(user.id, user.username, user.role ?? 'user');
// Create a unique auth session ID for this registration
const authSessionId = randomBytes(16).toString('hex');
// Record the session in PocketBase (best-effort, non-fatal)
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((err) =>
log.warn('auth', 'register: createUserSession failed (non-fatal)', { err: String(err) })
);
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
cookies.set(AUTH_COOKIE, token, {
path: '/',
httpOnly: true,

View File

@@ -0,0 +1,55 @@
<svelte:head>
<title>Privacy Policy — libnovel</title>
</svelte:head>
<div class="max-w-2xl mx-auto py-10 px-4">
<h1 class="text-2xl font-bold text-zinc-100 mb-6">Privacy Policy</h1>
<div class="space-y-5 text-sm text-zinc-400 leading-relaxed">
<p>
This policy describes what limited data libnovel collects and how it is used.
</p>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Data we collect</h2>
<ul class="list-disc list-inside space-y-2 pl-1">
<li>
<span class="text-zinc-300">Session cookies</span> — a short-lived cookie is set when you
visit the site to track reading progress across pages. No account is required.
</li>
<li>
<span class="text-zinc-300">Account data (optional)</span> — if you create an account,
we store your username and a hashed password. No email address is required.
</li>
<li>
<span class="text-zinc-300">Reading progress</span> — the last chapter you read for each
book is stored server-side, tied to your session or account, so you can resume reading.
</li>
<li>
<span class="text-zinc-300">Saved books</span> — books you explicitly bookmark are stored
server-side tied to your session or account.
</li>
</ul>
<h2 class="text-base font-semibold text-zinc-200 mt-6">What we do not collect</h2>
<ul class="list-disc list-inside space-y-2 pl-1">
<li>No email addresses (unless you choose to provide one).</li>
<li>No tracking pixels, analytics scripts, or third-party ad networks.</li>
<li>No selling or sharing of data with third parties.</li>
</ul>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Third-party content</h2>
<p>
Cover images and chapter content are fetched from third-party sources (e.g.
<a href="https://novelfire.net" target="_blank" rel="noopener noreferrer" class="text-amber-400 hover:text-amber-300 transition-colors">novelfire.net</a>).
Your browser may make requests directly to those domains when loading images.
</p>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Data deletion</h2>
<p>
You can delete your reading progress and saved books from your profile page at any time.
To request full account deletion, contact us via the <a href="/dmca" class="text-amber-400 hover:text-amber-300 transition-colors">contact address listed in our DMCA policy</a>.
</p>
<p class="text-zinc-600 text-xs mt-8">Last updated: {new Date().getFullYear()}</p>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More