Compare commits

...

79 Commits

Author SHA1 Message Date
Admin
c7b3495a23 fix(ios): wire avatar upload to presign flow with crop UI and cold-launch fix
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 28s
CI / UI / Docker Push (pull_request) Has been skipped
Release / Scraper / Test (push) Successful in 9s
Release / UI / Build (push) Successful in 28s
Release / Scraper / Docker (push) Successful in 24s
Release / UI / Docker (push) Successful in 3m6s
iOS CI / Build (push) Failing after 2m3s
iOS CI / Test (push) Has been skipped
iOS CI / Build (pull_request) Failing after 59s
iOS CI / Test (pull_request) Has been skipped
- Add AvatarCropView: fullscreen pan/pinch sheet with circular crop overlay,
  outputs 400×400 JPEG at 0.9 quality matching the web crop modal
- ProfileView: picker now shows crop sheet before uploading instead of direct upload
- AuthStore.validateToken: exchange raw MinIO key from /api/auth/me for a
  presigned GET URL so avatar renders correctly on cold launch / re-login
- APIClient: add fetchAvatarPresignedURL() calling GET /api/profile/avatar
- Models: add memberwise init to AppUser for avatar URL replacement
2026-03-10 18:17:01 +05:00
Admin
83a5910a59 feat: book commenting system with upvote/downvote + fix profile SSR crash
Some checks failed
CI / Scraper / Test (push) Successful in 10s
CI / Scraper / Lint (push) Successful in 12s
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 19s
CI / UI / Build (push) Successful in 32s
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
CI / UI / Docker Push (push) Failing after 11s
CI / Scraper / Docker Push (push) Successful in 45s
iOS CI / Build (push) Failing after 2m44s
iOS CI / Test (push) Has been skipped
iOS CI / Build (pull_request) Failing after 5m46s
iOS CI / Test (pull_request) Has been skipped
- Add book_comments and comment_votes PocketBase collections (pb-init.sh + pocketbase.go EnsureCollections)
- Web: CommentsSection.svelte with post form, vote buttons, lazy-loaded per-book
- API routes: GET/POST /api/comments/[slug], POST /api/comments/[id]/vote
- iOS: BookComment + CommentsResponse models, fetchComments/postComment/voteComment in APIClient, CommentsView + CommentsViewModel wired into BookDetailView
- Fix profile page SSR crash (ERR_MODULE_NOT_FOUND cropperjs): lazy-load AvatarCropModal via dynamic import guarded by browser, move URL.createObjectURL into onMount
2026-03-10 18:05:41 +05:00
Admin
0f6639aae7 feat(ui): avatar crop modal, health endpoint, leaner Dockerfile
All checks were successful
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 17s
CI / UI / Build (pull_request) Successful in 24s
Release / Scraper / Test (push) Successful in 17s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Build (push) Successful in 46s
Release / UI / Build (push) Successful in 15s
Release / UI / Docker (push) Successful in 41s
Release / Scraper / Docker (push) Successful in 5m32s
CI / UI / Docker Push (push) Successful in 8m13s
iOS CI / Build (pull_request) Successful in 8m38s
iOS CI / Test (pull_request) Successful in 13m23s
- Add AvatarCropModal.svelte using cropperjs v1: 1:1 crop, 400×400 output,
  JPEG/WebP output, dark glassmorphic UI
- Rewrite profile page avatar upload to use presigned PUT flow (POST→PUT→PATCH)
  instead of sending raw FormData directly; crop modal opens on file select
- Add GET /health → {status:ok} for Docker healthcheck
- Simplify Dockerfile: drop runtime npm ci (adapter-node bundles all deps)
- Fix docker-compose UI healthcheck: /health route, 127.0.0.1 to avoid
  IPv6 localhost resolution failure in alpine busybox wget
2026-03-10 17:46:37 +05:00
Admin
88a25bc33e refactor(ios): cleanup pass — dead code removal and component consolidation
All checks were successful
CI / Scraper / Lint (pull_request) Successful in 16s
CI / Scraper / Test (pull_request) Successful in 20s
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 7m18s
iOS CI / Build (pull_request) Successful in 5m22s
iOS CI / Test (push) Successful in 10m55s
iOS CI / Test (pull_request) Successful in 9m32s
- Remove dead structs: ReadingProgress, HomeData, PBList (Models.swift)
- Remove unused APIClient members: fetchRaw, setSessionId, BrowseParams; simplify logout(); drop no-op CodingKeys from BrowseResponse
- Remove absolutePrevChapter/absoluteNextChapter computed props (replaced by prevChapter/nextChapter); replace URLSession cover prefetch with Kingfisher
- Drop scrollOffset state var and dead subtitle block in ChapterRow (BookDetailView)
- Remove never-set chaptersLoading published prop (BookDetailViewModel)
- Add unified ChipButton(.filled/.outlined) to CommonViews replacing three near-identical pill chip types
- Replace FilterChipView+SortChip in LibraryView and FilterChip in BrowseView with ChipButton
- Replace inline KFImage usage in HomeView with AsyncCoverImage
- Fix deprecated .navigationBarHidden(true) → .toolbar(.hidden, for: .navigationBar) in AuthView
2026-03-10 17:13:45 +05:00
Admin
73ad4ece49 ci: add release-scraper workflow triggered on v* tags
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 16s
CI / Scraper / Test (pull_request) Successful in 16s
CI / UI / Build (pull_request) Successful in 23s
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 11m25s
iOS CI / Test (pull_request) Has been skipped
2026-03-10 17:08:24 +05:00
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
75 changed files with 7517 additions and 1155 deletions

View File

@@ -2,18 +2,20 @@ name: CI / Scraper
on:
push:
branches: ["main", "master"]
branches: ["main", "master", "v2"]
paths:
- "scraper/**"
- "justfile"
- ".gitea/workflows/ci-scraper.yaml"
pull_request:
branches: ["main", "master"]
branches: ["main", "master", "v2"]
paths:
- "scraper/**"
- "justfile"
- ".gitea/workflows/ci-scraper.yaml"
concurrency:
group: ${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
# ── lint & vet ───────────────────────────────────────────────────────────────
lint:
@@ -27,11 +29,11 @@ jobs:
go-version-file: scraper/go.mod
cache-dependency-path: scraper/go.sum
- name: Install just
uses: extractions/setup-just@v2
- name: go vet
run: just lint
working-directory: scraper
run: |
go vet ./...
go vet -tags integration ./...
# ── tests ────────────────────────────────────────────────────────────────────
test:
@@ -45,36 +47,30 @@ jobs:
go-version-file: scraper/go.mod
cache-dependency-path: scraper/go.sum
- name: Install just
uses: extractions/setup-just@v2
- name: Run tests
run: just test
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: Install just
uses: extractions/setup-just@v2
- name: Build binary
run: |
cd scraper && 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@v4
- 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,23 +2,28 @@ name: CI / UI
on:
push:
branches: ["main", "master"]
branches: ["main", "master", "v2"]
paths:
- "ui/**"
- "justfile"
- ".gitea/workflows/ci-ui.yaml"
pull_request:
branches: ["main", "master"]
branches: ["main", "master", "v2"]
paths:
- "ui/**"
- "justfile"
- ".gitea/workflows/ci-ui.yaml"
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
@@ -28,14 +33,35 @@ jobs:
cache: npm
cache-dependency-path: ui/package-lock.json
- name: Install just
uses: extractions/setup-just@v2
- name: Install dependencies
run: just ui-install
run: npm ci
- name: Type check
run: just ui-check
run: npm run check
- name: Build
run: just ui-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; }

View File

@@ -7,15 +7,21 @@ on:
- "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 ─────────────────────────────────────────────────────────────────
# ── build (simulator) ─────────────────────────────────────────────────────
build:
name: Build
runs-on: macos-latest
@@ -24,15 +30,14 @@ jobs:
- uses: actions/checkout@v4
- name: Install just
run: brew install just
- name: Install xcodegen
run: brew install xcodegen
run: command -v just || brew install just
- name: Build (simulator)
env:
USER: runner
run: just ios-build
# ── test ──────────────────────────────────────────────────────────────────
# ── unit tests ────────────────────────────────────────────────────────────
test:
name: Test
runs-on: macos-latest
@@ -42,12 +47,11 @@ jobs:
- uses: actions/checkout@v4
- name: Install just
run: brew install just
- name: Install xcodegen
run: brew install xcodegen
run: command -v just || brew install just
- name: Run unit tests
env:
USER: runner
run: just ios-test
- name: Upload test results
@@ -57,53 +61,3 @@ jobs:
name: test-results
path: ios/LibNovel/test-results.xml
retention-days: 7
# ── archive (release IPA) ─────────────────────────────────────────────────
# Runs only on pushes to v2/main (not PRs).
# Requires secrets: APPLE_CERTIFICATE_BASE64, APPLE_CERTIFICATE_PASSWORD,
# APPLE_PROVISIONING_PROFILE_BASE64, KEYCHAIN_PASSWORD
# Also requires ios/LibNovel/ExportOptions.plist to exist.
#
# archive:
# name: Archive
# runs-on: macos-latest
# needs: [build, test]
# if: gitea.event_name == 'push'
#
# steps:
# - uses: actions/checkout@v4
#
# - name: Install just
# run: brew install just
#
# - name: Install xcodegen
# run: brew install xcodegen
#
# - name: Import signing certificate
# env:
# CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
# CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
# KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
# run: |
# KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
# security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
# security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# echo "$CERTIFICATE_BASE64" | base64 --decode > $RUNNER_TEMP/cert.p12
# security import $RUNNER_TEMP/cert.p12 \
# -P "$CERTIFICATE_PASSWORD" \
# -A -t cert -f pkcs12 \
# -k $KEYCHAIN_PATH
# security list-keychain -d user -s $KEYCHAIN_PATH
#
# - name: Import provisioning profile
# env:
# PROFILE_BASE64: ${{ secrets.APPLE_PROVISIONING_PROFILE_BASE64 }}
# run: |
# PP_PATH=$RUNNER_TEMP/profile.mobileprovision
# echo "$PROFILE_BASE64" | base64 --decode > $PP_PATH
# mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
# cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/
#
# - name: Archive & export IPA
# run: just ios-archive && just ios-export

View File

@@ -0,0 +1,65 @@
name: Release / Scraper
on:
push:
tags:
- "v*"
concurrency:
group: ${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
# ── lint & test ──────────────────────────────────────────────────────────────
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: scraper/go.mod
cache-dependency-path: scraper/go.sum
- name: go vet
working-directory: scraper
run: |
go vet ./...
go vet -tags integration ./...
- name: Run tests
working-directory: scraper
run: go test -short -race -count=1 -timeout=60s ./...
# ── docker build & push ──────────────────────────────────────────────────────
docker:
name: Docker
runs-on: ubuntu-latest
needs: [test]
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: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_USER }}/libnovel-scraper
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v6
with:
context: scraper
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

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:-}"
@@ -147,7 +149,7 @@ services:
ports:
- "${UI_PORT:-5252}:3000"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/"]
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 15s
timeout: 5s
retries: 3

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

@@ -14,6 +14,7 @@
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 */; };
@@ -24,13 +25,11 @@
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.swift */; };
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */; };
A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB13E89E50529E3081533A66 /* AudioPlayerService.swift */; };
BD2CA5EE70D102CA3B153485 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 6313AB4B3A5464F647791174 /* MarkdownUI */; };
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */; };
C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB2E843D93461074A89A171 /* HomeViewModel.swift */; };
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 09584EAB68A07B47F876A062 /* Kingfisher */; };
E1F564399D1325F6A1B2B84F /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C21107BECA55C07416E0CB8B /* LibraryView.swift */; };
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D83BB88C4306BE7A4F947CB /* Color+App.swift */; };
A1B2C3D4E5F6789012345678 /* String+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F67890123456789A /* String+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 */; };
@@ -51,7 +50,7 @@
/* 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; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LibNovelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LibNovelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
2D5C115992F1CE2326236765 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = "<group>"; };
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>"; };
@@ -68,7 +67,6 @@
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>"; };
B2C3D4E5F67890123456789A /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+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>"; };
@@ -78,6 +76,7 @@
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 */
@@ -86,7 +85,6 @@
buildActionMask = 2147483647;
files = (
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */,
BD2CA5EE70D102CA3B153485 /* MarkdownUI in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -276,7 +274,7 @@
children = (
9D83BB88C4306BE7A4F947CB /* Color+App.swift */,
7CAFB96D2500F34F0B0C860C /* NavDestination.swift */,
B2C3D4E5F67890123456789A /* String+App.swift */,
FEC6F837FF2E902E334ED72E /* String+App.swift */,
);
path = Extensions;
sourceTree = "<group>";
@@ -327,7 +325,6 @@
name = LibNovel;
packageProductDependencies = (
09584EAB68A07B47F876A062 /* Kingfisher */,
6313AB4B3A5464F647791174 /* MarkdownUI */,
);
productName = LibNovel;
productReference = 1B8BF3DB582A658386E402C7 /* LibNovel.app */;
@@ -340,7 +337,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1600;
LastUpgradeCheck = 2630;
};
buildConfigurationList = D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */;
developmentRegion = en;
@@ -353,7 +350,6 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */,
C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 6318D3C6F0DC6C8E2C377103 /* Products */;
@@ -401,7 +397,6 @@
FEFB5FDC2424D22914458001 /* ChapterReaderView.swift in Sources */,
2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */,
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */,
A1B2C3D4E5F6789012345678 /* String+App.swift in Sources */,
F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */,
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */,
EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */,
@@ -415,6 +410,7 @@
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */,
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */,
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */,
41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -439,7 +435,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel.tests;
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel.tests;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LibNovel.app/LibNovel";
@@ -456,7 +452,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel.tests;
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel.tests;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LibNovel.app/LibNovel";
@@ -467,16 +463,21 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "iPhone Developer";
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",
);
PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel;
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel;
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
@@ -516,11 +517,12 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
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;
@@ -543,6 +545,7 @@
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;
@@ -553,16 +556,24 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "iPhone Developer";
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",
);
PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel;
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";
};
@@ -602,11 +613,12 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
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;
@@ -622,6 +634,7 @@
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";
@@ -670,14 +683,6 @@
minimumVersion = 8.0.0;
};
};
C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.4.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@@ -686,11 +691,6 @@
package = AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */;
productName = Kingfisher;
};
6313AB4B3A5464F647791174 /* MarkdownUI */ = {
isa = XCSwiftPackageProductDependency;
package = C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
productName = MarkdownUI;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = A10A669C0C8B43078C0FEE9F /* Project object */;

View File

@@ -1,5 +1,5 @@
{
"originHash" : "18350c2bfa3935125b6f4e9817e7ed4508588c07142d420b8b8ee00640a57853",
"originHash" : "ad75ae2d3b8d8b80d99635f65213a3c1092464aa54a86354f850b8317b6fa240",
"pins" : [
{
"identity" : "kingfisher",
@@ -9,33 +9,6 @@
"revision" : "c92b84898e34ab46ff0dad86c02a0acbe2d87008",
"version" : "8.8.0"
}
},
{
"identity" : "networkimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/NetworkImage",
"state" : {
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
"version" : "6.0.1"
}
},
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-cmark",
"state" : {
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
"version" : "0.7.1"
}
},
{
"identity" : "swift-markdown-ui",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
"state" : {
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
"version" : "2.4.1"
}
}
],
"version" : 3

View File

@@ -1,11 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
version = "1.7">
LastUpgradeVersion = "2630"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
@@ -27,8 +26,7 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
@@ -51,8 +49,6 @@
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
@@ -74,12 +70,10 @@
ReferencedContainer = "container:LibNovel.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "LIBNOVEL_BASE_URL"
value = "[&quot;isEnabled&quot;: true, &quot;value&quot;: &quot;https://v2.libnovel.kalekber.cc&quot;]"
value = "[&quot;value&quot;: &quot;https://v2.libnovel.kalekber.cc&quot;, &quot;isEnabled&quot;: true]"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
@@ -100,8 +94,6 @@
ReferencedContainer = "container:LibNovel.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">

View File

@@ -27,15 +27,15 @@ struct RootTabView: View {
.tag(Tab.home)
LibraryView()
.tabItem { Label("Library", systemImage: "books.vertical.fill") }
.tabItem { Label("Library", systemImage: "book.pages.fill") }
.tag(Tab.library)
BrowseView()
.tabItem { Label("Discover", systemImage: "globe") }
.tabItem { Label("Discover", systemImage: "sparkles") }
.tag(Tab.browse)
ProfileView()
.tabItem { Label("Profile", systemImage: "person.fill") }
.tabItem { Label("Profile", systemImage: "gear") }
.tag(Tab.profile)
}
// Reserve space for the mini-player above the tab bar so scroll content

View File

@@ -3,7 +3,6 @@ import SwiftUI
// MARK: - App accent color (amber mirrors Tailwind amber-500 #f59e0b)
extension Color {
static let amber = Color(red: 0.96, green: 0.62, blue: 0.04)
static let amberLight = Color(red: 1.0, green: 0.84, blue: 0.40)
}
extension ShapeStyle where Self == Color {

View File

@@ -6,3 +6,95 @@ 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

@@ -3,18 +3,28 @@ import Foundation
// MARK: - String helpers for display purposes
extension String {
/// Strips trailing relative-date suffixes (e.g. "2 years ago", "3 days ago")
/// that may be embedded in chapter titles coming from older scraped records.
/// 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 }
// Walk backwards past whitespace to find the numeric token
// Everything before the suffix
let before = String(self[self.startIndex ..< suffixRange.lowerBound])
let trimmed = before.trimmingCharacters(in: .whitespaces)
// Find start of last word (should be a number)
// 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 {
@@ -22,7 +32,6 @@ extension String {
.trimmingCharacters(in: .whitespaces)
}
} else if Int(trimmed) != nil {
// The entire string is just a number + "ago"
return ""
}
}
@@ -35,6 +44,6 @@ extension String {
enum AppLayout {
/// Height of the persistent mini-player bar:
/// 2pt progress line + 20pt vertical padding + ~44pt content row.
static let miniPlayerBarHeight: CGFloat = 66
/// 12pt vertical padding (top) + 56pt cover height + 12pt vertical padding (bottom) + 12pt horizontal margin.
static let miniPlayerBarHeight: CGFloat = 92
}

View File

@@ -1,4 +1,5 @@
import Foundation
import SwiftUI
// MARK: - Book
@@ -72,27 +73,6 @@ struct ChapterIndexBrief: Codable, Hashable {
let title: String
}
// MARK: - Progress
struct ReadingProgress: Codable {
var id: String?
let sessionId: String
var userId: String?
let slug: String
var chapter: Int
var audioTime: Double?
let updated: String
enum CodingKeys: String, CodingKey {
case id
case sessionId = "session_id"
case userId = "user_id"
case slug, chapter
case audioTime = "audio_time"
case updated
}
}
// MARK: - User Settings
struct UserSettings: Codable {
@@ -107,6 +87,81 @@ struct UserSettings: Codable {
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 {
@@ -114,34 +169,33 @@ struct AppUser: Codable, Identifiable {
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(id: String, username: String, role: String, created: String, avatarURL: String?) {
self.id = id
self.username = username
self.role = role
self.created = created
self.avatarURL = avatarURL
}
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) ?? ""
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: - Browse / Novel Listing
struct NovelListing: Identifiable, Hashable {
var id: String { slug.isEmpty ? url : slug }
let slug: String
let title: String
let cover: String
let rank: String
let rating: String
let chapters: String
let url: String
let author: String
let status: String
let genres: [String]
}
// MARK: - Ranking
struct RankingItem: Codable, Identifiable {
@@ -163,12 +217,6 @@ struct RankingItem: Codable, Identifiable {
// MARK: - Home
struct HomeData {
let continueReading: [ContinueReadingItem]
let recentlyUpdated: [Book]
let stats: HomeStats
}
struct ContinueReadingItem: Identifiable {
var id: String { book.id }
let book: Book
@@ -201,17 +249,6 @@ struct UserSession: Codable, Identifiable {
}
}
// MARK: - Book Detail
struct BookDetailData {
let book: Book
let chapters: [ChapterIndex]
let previewChapters: [PreviewChapter]?
let inLib: Bool
let saved: Bool
let lastChapter: Int?
}
struct PreviewChapter: Codable, Identifiable {
var id: Int { number }
let number: Int
@@ -219,34 +256,54 @@ struct PreviewChapter: Codable, Identifiable {
let url: String
}
// MARK: - Chapter Content
struct ChapterContent {
let book: BookBrief
let chapter: ChapterIndex
let html: String
let voices: [String]
let prev: Int?
let next: Int?
let chapters: [ChapterIndexBrief]
let isPreview: Bool
}
struct BookBrief: Codable {
let slug: String
let title: String
let cover: String
}
// MARK: - Comments
struct BookComment: Identifiable, Codable, Hashable {
let id: String
let slug: String
let userId: String
let username: String
let body: String
var upvotes: Int
var downvotes: Int
let created: String
enum CodingKeys: String, CodingKey {
case id, slug, username, body, upvotes, downvotes, created
case userId = "user_id"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? ""
userId = try c.decodeIfPresent(String.self, forKey: .userId) ?? ""
username = try c.decodeIfPresent(String.self, forKey: .username) ?? ""
body = try c.decodeIfPresent(String.self, forKey: .body) ?? ""
upvotes = try c.decodeIfPresent(Int.self, forKey: .upvotes) ?? 0
downvotes = try c.decodeIfPresent(Int.self, forKey: .downvotes) ?? 0
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
}
}
struct CommentsResponse: Decodable {
let comments: [BookComment]
let myVotes: [String: String]
enum CodingKeys: String, CodingKey {
case comments
case myVotes = "myVotes"
}
}
// MARK: - Audio
enum NextPrefetchStatus {
case none, prefetching, prefetched, failed
}
// MARK: - PocketBase list response
struct PBList<T: Codable>: Codable {
let items: [T]
let totalItems: Int
}

View File

@@ -11,7 +11,6 @@ actor APIClient {
var baseURL: URL
private var authCookie: String? // raw "libnovel_auth=<token>" header value
private var sessionId: String? // anon session id (UUID)
// URLSession with persistent cookie storage
private let session: URLSession = {
@@ -51,10 +50,6 @@ actor APIClient {
}
}
func setSessionId(_ id: String) {
sessionId = id
}
// MARK: - Low-level request builder
private func makeRequest(_ path: String, method: String = "GET", body: Encodable? = nil) throws -> URLRequest {
@@ -63,9 +58,7 @@ actor APIClient {
// paths like /api/chapter/slug/1. URL(string:) preserves slashes correctly.
let urlString = baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
print("[APIClient] ▶ \(method) \(urlString)")
guard let url = URL(string: urlString) else {
print("[APIClient] ✗ Could not construct URL from: \(urlString)")
throw APIError.invalidResponse
}
var req = URLRequest(url: url)
@@ -75,12 +68,6 @@ actor APIClient {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONEncoder().encode(body)
}
// Log cookies being sent
if let cookies = HTTPCookieStorage.shared.cookies(for: url), !cookies.isEmpty {
print("[APIClient] cookies: \(cookies.map { "\($0.name)=\($0.value.prefix(20))" })")
} else {
print("[APIClient] cookies: (none)")
}
return req
}
@@ -90,51 +77,19 @@ actor APIClient {
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 {
print("[APIClient] ✗ \(path) — not an HTTP response")
throw APIError.invalidResponse
}
let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8 data, \(data.count) bytes>"
print("[APIClient] ◀ \(http.statusCode) \(path)\(data.count) bytes")
if data.count < 2000 {
print("[APIClient] body: \(rawBody)")
} else {
print("[APIClient] body (first 2000 chars): \(rawBody.prefix(2000))")
}
guard (200..<300).contains(http.statusCode) else {
throw APIError.httpError(http.statusCode, rawBody)
}
do {
let decoded = try JSONDecoder.iso8601.decode(T.self, from: data)
print("[APIClient] ✓ decoded \(T.self) from \(path)")
return decoded
return try JSONDecoder.iso8601.decode(T.self, from: data)
} catch {
print("[APIClient] ✗ decode error for \(T.self) from \(path)")
print("[APIClient] decode error: \(error)")
if let decodingError = error as? DecodingError {
switch decodingError {
case .typeMismatch(let type, let ctx):
print("[APIClient] typeMismatch: expected \(type) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
case .valueNotFound(let type, let ctx):
print("[APIClient] valueNotFound: \(type) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
case .keyNotFound(let key, let ctx):
print("[APIClient] keyNotFound: '\(key.stringValue)' at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
case .dataCorrupted(let ctx):
print("[APIClient] dataCorrupted: \(ctx.debugDescription) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
@unknown default:
print("[APIClient] unknown decoding error: \(error)")
}
}
throw APIError.decodingError(error)
}
}
func fetchRaw(_ path: String, method: String = "GET", body: Encodable? = nil) async throws -> (Data, HTTPURLResponse) {
let req = try makeRequest(path, method: method, body: body)
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
return (data, http)
}
// MARK: - Auth
struct LoginRequest: Encodable {
@@ -158,7 +113,7 @@ actor APIClient {
}
func logout() async throws {
let (_, _) = try await fetchRaw("/api/auth/logout", method: "POST")
let _: EmptyResponse = try await fetch("/api/auth/logout", method: "POST")
await setAuthCookie(nil)
}
@@ -196,13 +151,6 @@ actor APIClient {
// MARK: - Browse
struct BrowseParams: Encodable {
let page: Int
let genre: String
let sort: String
let status: String
}
func browse(page: Int, genre: String = "all", sort: String = "popular", status: String = "all") async throws -> BrowseResponse {
let query = "?page=\(page)&genre=\(genre)&sort=\(sort)&status=\(status)"
return try await fetch("/api/browse-page\(query)")
@@ -316,6 +264,83 @@ actor APIClient {
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
}
/// Fetches a fresh presigned GET URL for the current user's avatar.
/// Returns nil if the user has no avatar set.
/// Used on cold launch / session restore to convert the stored raw key into a viewable URL.
func fetchAvatarPresignedURL() async throws -> String? {
let result: AvatarResponse = try await fetch("/api/profile/avatar")
return result.avatarURL
}
// MARK: - Comments
func fetchComments(slug: String) async throws -> CommentsResponse {
try await fetch("/api/comments/\(slug)")
}
struct PostCommentBody: Encodable { let body: String }
func postComment(slug: String, body: String) async throws -> BookComment {
try await fetch("/api/comments/\(slug)", method: "POST", body: PostCommentBody(body: body))
}
struct VoteBody: Encodable { let vote: String }
/// Cast, change, or toggle-off a vote on a comment.
/// Returns the updated BookComment (with refreshed upvotes/downvotes counts).
func voteComment(commentId: String, vote: String) async throws -> BookComment {
try await fetch("/api/comments/\(commentId)/vote", method: "POST", body: VoteBody(vote: vote))
}
}
// MARK: - Response types
@@ -386,11 +411,6 @@ struct BrowseResponse: Decodable {
let novels: [BrowseNovel]
let page: Int
let hasNext: Bool
enum CodingKeys: String, CodingKey {
case novels, page
case hasNext = "hasNext"
}
}
struct BrowseNovel: Decodable, Identifiable, Hashable {

View File

@@ -3,7 +3,7 @@
{
"color": {
"color-space": "srgb",
"components": { "alpha": "1.000", "blue": "0.588", "green": "0.467", "red": "1.000" }
"components": { "alpha": "1.000", "blue": "0.040", "green": "0.620", "red": "0.960" }
},
"idiom": "universal"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@@ -2,22 +2,30 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleName</key>
<string>LibNovel</string>
<key>CFBundleDisplayName</key>
<string>LibNovel</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>cc.kalekber.libnovel</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<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>
@@ -31,13 +39,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UILaunchScreen</key>
<dict/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>LIBNOVEL_BASE_URL</key>
<string>$(LIBNOVEL_BASE_URL)</string>
</dict>
</plist>

View File

@@ -2,6 +2,20 @@ 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
@@ -27,12 +41,33 @@ final class AudioPlayerService: ObservableObject {
@Published var errorMessage: String = ""
@Published var generationProgress: Double = 0
@Published var currentTime: Double = 0
@Published var duration: Double = 0
@Published var isPlaying: Bool = false
/// 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 = ""
@@ -60,6 +95,14 @@ final class AudioPlayerService: ObservableObject {
// 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
@@ -73,7 +116,7 @@ final class AudioPlayerService: ObservableObject {
/// 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?) {
chapters: [ChapterIndexBrief], nextChapter: Int?, prevChapter: Int?) {
generationTask?.cancel()
prefetchTask?.cancel()
stop()
@@ -87,9 +130,15 @@ final class AudioPlayerService: ObservableObject {
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
@@ -139,6 +188,69 @@ final class AudioPlayerService: ObservableObject {
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()
@@ -148,6 +260,15 @@ final class AudioPlayerService: ObservableObject {
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
@@ -313,6 +434,18 @@ final class AudioPlayerService: ObservableObject {
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(
@@ -328,6 +461,7 @@ final class AudioPlayerService: ObservableObject {
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.
@@ -338,12 +472,18 @@ final class AudioPlayerService: ObservableObject {
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)
@@ -360,7 +500,8 @@ final class AudioPlayerService: ObservableObject {
voice: voice,
speed: speed,
chapters: chapters,
nextChapter: nextNextChapter
nextChapter: nextNextChapter,
prevChapter: nextPrevChapter
)
}
}
@@ -369,14 +510,17 @@ final class AudioPlayerService: ObservableObject {
private func prefetchCoverArtwork(from urlString: String) {
guard !urlString.isEmpty, let url = URL(string: urlString) else { return }
URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
guard let self, let data, let image = UIImage(data: data) else { return }
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
Task { @MainActor in
self.cachedCoverArtwork = artwork
self.updateNowPlaying()
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()
}
}
}.resume()
}
}
// MARK: - Audio Session
@@ -406,9 +550,9 @@ final class AudioPlayerService: ObservableObject {
self?.togglePlayPause()
return .success
}
center.skipForwardCommand.preferredIntervals = [30]
center.skipForwardCommand.preferredIntervals = [15]
center.skipForwardCommand.addTarget { [weak self] _ in
self?.skip(by: 30)
self?.skip(by: 15)
return .success
}
center.skipBackwardCommand.preferredIntervals = [15]
@@ -460,6 +604,13 @@ enum AudioPlayerStatus: Equatable {
}
}
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

@@ -85,7 +85,14 @@ final class AuthStore: ObservableObject {
}
}
// MARK: - Token validation (on cold launch)
// 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)
@@ -93,7 +100,20 @@ final class AuthStore: ObservableObject {
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)
var (restoredUser, restoredSettings) = try await (me, s)
// /api/auth/me returns the raw MinIO object key for avatar_url, not a presigned URL.
// Exchange the key for a fresh presigned GET URL so KFImage can display it.
if let key = restoredUser.avatarURL, !key.hasPrefix("http") {
if let presignedURL = try? await APIClient.shared.fetchAvatarPresignedURL() {
restoredUser = AppUser(
id: restoredUser.id,
username: restoredUser.username,
role: restoredUser.role,
created: restoredUser.created,
avatarURL: presignedURL
)
}
}
user = restoredUser
settings = restoredSettings
} catch let e as APIError {

View File

@@ -9,7 +9,6 @@ final class BookDetailViewModel: ObservableObject {
@Published var saved: Bool = false
@Published var lastChapter: Int?
@Published var isLoading = false
@Published var chaptersLoading = false
@Published var error: String?
init(slug: String) {

View File

@@ -50,14 +50,12 @@ final class BrowseViewModel: ObservableObject {
}
private func loadPage(_ page: Int) async {
print("[Browse] loadPage(\(page)) genre=\(genre) sort=\(sort) status=\(status)")
isLoading = true
error = nil
do {
let result = try await APIClient.shared.browse(
page: page, genre: genre, sort: sort, status: status
)
print("[Browse] ✓ page \(page)\(result.novels.count) novels, hasNext=\(result.hasNext)")
if page == 1 {
novels = result.novels
} else {
@@ -67,7 +65,6 @@ final class BrowseViewModel: ObservableObject {
currentPage = page
} catch {
if !(error is CancellationError) {
print("[Browse] ✗ error on page \(page): \(error)")
self.error = error.localizedDescription
}
}

View File

@@ -3,34 +3,38 @@ import Foundation
@MainActor
final class ChapterReaderViewModel: ObservableObject {
let slug: String
let chapter: Int
private(set) var chapter: Int
@Published var content: ChapterResponse?
@Published var isLoading = false
@Published var error: String?
@Published var navigateTo: Int? // set to trigger navigation to next chapter
init(slug: String, chapter: Int) {
self.slug = slug
self.chapter = chapter
}
/// 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 {
print("[ChapterReader] load() slug=\(slug) chapter=\(chapter)")
isLoading = true
error = nil
do {
content = try await APIClient.shared.chapterContent(slug: slug, chapter: chapter)
print("[ChapterReader] ✓ loaded chapter \(chapter), html length=\(content?.html.count ?? 0)")
// Record reading progress
try? await APIClient.shared.setProgress(slug: slug, chapter: chapter)
} catch {
if !(error is CancellationError) {
print("[ChapterReader] ✗ error: \(error)")
self.error = error.localizedDescription
}
}
print("[ChapterReader] done — isLoading=false, content=\(content != nil), error=\(String(describing: self.error))")
isLoading = false
}
@@ -47,6 +51,7 @@ final class ChapterReaderViewModel: ObservableObject {
audioPlayer.togglePlayPause()
} else {
let nextChapter: Int? = content.next
let prevChapter: Int? = content.prev
audioPlayer.load(
slug: slug,
chapter: chapter,
@@ -56,7 +61,8 @@ final class ChapterReaderViewModel: ObservableObject {
voice: settings.voice,
speed: settings.speed,
chapters: content.chapters,
nextChapter: nextChapter
nextChapter: nextChapter,
prevChapter: prevChapter
)
}
}

View File

@@ -96,7 +96,7 @@ struct AuthView: View {
Spacer()
}
.navigationBarHidden(true)
.toolbar(.hidden, for: .navigationBar)
}
.onChange(of: mode) { _, _ in
authStore.error = nil

View File

@@ -1,4 +1,5 @@
import SwiftUI
import Kingfisher
struct BookDetailView: View {
let slug: String
@@ -15,24 +16,28 @@ struct BookDetailView: View {
}
var body: some View {
ScrollView {
if vm.isLoading {
ProgressView().frame(maxWidth: .infinity).padding(.top, 80)
} else if let book = vm.book {
ZStack(alignment: .top) {
// Scroll content
ScrollView {
VStack(alignment: .leading, spacing: 0) {
heroSection(book: book)
Divider().padding(.vertical, 8)
chapterSection(book: book)
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)
Divider().padding(.horizontal)
CommentsView(slug: slug)
}
}
}
.ignoresSafeArea(edges: .top)
}
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar { bookmarkButton }
.task { await vm.load() }
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
Button("OK") { vm.error = nil }
} message: { Text(vm.error ?? "") }
.errorAlert($vm.error)
}
// MARK: - Hero
@@ -40,85 +45,148 @@ struct BookDetailView: View {
@ViewBuilder
private func heroSection(book: Book) -> some View {
ZStack(alignment: .bottom) {
// Blurred cover background use plain colour placeholder to avoid
// the rounded-rect loading indicator showing through the blur.
AsyncCoverImage(url: book.cover, isBackground: true)
// Full-bleed blurred background
KFImage(URL(string: book.cover))
.resizable()
.scaledToFill()
.frame(maxWidth: .infinity)
.frame(height: 260)
.blur(radius: 20)
.frame(height: 320)
.blur(radius: 24)
.clipped()
.overlay(Color.black.opacity(0.45))
.overlay(
LinearGradient(
colors: [.black.opacity(0.15), .black.opacity(0.68)],
startPoint: .top,
endPoint: .bottom
)
)
HStack(alignment: .bottom, spacing: 14) {
AsyncCoverImage(url: book.cover)
.frame(width: 110, height: 160)
.clipShape(RoundedRectangle(cornerRadius: 10))
.shadow(radius: 8)
// 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)
VStack(alignment: .leading, spacing: 6) {
// Title + author
VStack(spacing: 6) {
Text(book.title)
.font(.headline)
.font(.title3.bold())
.foregroundStyle(.white)
.multilineTextAlignment(.center)
.lineLimit(3)
.padding(.horizontal, 32)
Text(book.author)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.8))
HStack {
TagChip(label: book.status).colorScheme(.dark)
ForEach(book.genres.prefix(2), id: \.self) {
TagChip(label: $0).colorScheme(.dark)
.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)
}
}
}
Spacer(minLength: 0)
// Status badge
if !book.status.isEmpty {
StatusBadge(status: book.status)
}
}
.padding(.horizontal)
.padding(.bottom, 16)
.padding(.bottom, 28)
}
.frame(minHeight: 320)
}
// Summary
VStack(alignment: .leading, spacing: 8) {
Text(book.summary)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(summaryExpanded ? nil : 4)
if book.summary.count > 200 {
Button(summaryExpanded ? "Less" : "More") {
withAnimation { summaryExpanded.toggle() }
// 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")
}
.font(.caption.bold())
.foregroundStyle(.amber)
}
}
.padding()
.padding(.vertical, 16)
.frame(maxWidth: .infinity)
// CTA buttons
HStack(spacing: 10) {
if let last = vm.lastChapter, last > 0 {
NavigationLink(value: NavDestination.chapter(slug, last)) {
Label("Continue Ch.\(last)", systemImage: "play.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.amber)
Divider().padding(.horizontal)
NavigationLink(value: NavDestination.chapter(slug, 1)) {
Label("From Ch.1", systemImage: "arrow.counterclockwise")
.frame(maxWidth: .infinity)
// 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)
}
.buttonStyle(.bordered)
.tint(.secondary)
} else {
NavigationLink(value: NavDestination.chapter(slug, 1)) {
Label("Start Reading", systemImage: "book.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.amber)
}
.padding(.horizontal)
.padding(.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)
}
.padding(.horizontal)
.padding(.bottom, 8)
}
// MARK: - Chapter list
@@ -132,9 +200,10 @@ struct BookDetailView: View {
let pageChapters = Array(chapters[start..<end])
VStack(alignment: .leading, spacing: 0) {
// Section header
HStack {
Text("Chapters")
.font(.title3.bold())
.font(.headline)
Spacer()
if total > 0 {
Text("\(start + 1)\(end) of \(total)")
@@ -143,36 +212,58 @@ struct BookDetailView: View {
}
}
.padding(.horizontal)
.padding(.vertical, 10)
.padding(.vertical, 14)
if vm.chaptersLoading {
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)
ChapterRow(chapter: ch, isCurrent: ch.number == vm.lastChapter,
totalChapters: total)
}
.buttonStyle(.plain)
Divider().padding(.leading)
}
}
// Pagination
// Pagination bar
if total > pageSize {
HStack {
Button("Previous") { chapterPage -= 1 }
.disabled(chapterPage == 0)
Button {
withAnimation { chapterPage -= 1 }
} label: {
Image(systemName: "chevron.left")
Text("Previous")
}
.disabled(chapterPage == 0)
Spacer()
Button("Next") { chapterPage += 1 }
.disabled(end >= total)
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)
}
.buttonStyle(.bordered)
.font(.subheadline)
.foregroundStyle(.amber)
.padding()
}
Color.clear.frame(height: 32)
}
}
// MARK: - Toolbar bookmark
// MARK: - Bookmark toolbar
@ToolbarContentBuilder
private var bookmarkButton: some ToolbarContent {
@@ -187,38 +278,113 @@ struct BookDetailView: View {
}
}
// 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: 8) {
VStack(alignment: .leading, spacing: 2) {
Text("Chapter \(chapter.number)")
.font(.subheadline)
.fontWeight(isCurrent ? .bold : .regular)
.foregroundStyle(isCurrent ? .amber : .primary)
if !chapter.title.isEmpty && chapter.title != "Chapter \(chapter.number)" {
Text(chapter.title)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
HStack(spacing: 10) {
// Number badge
ZStack {
Circle()
.fill(isCurrent ? Color.amber : Color(.systemGray6))
Text("\(chapter.number)")
.font(.caption2.bold().monospacedDigit())
.foregroundStyle(isCurrent ? .black : .secondary)
}
Spacer(minLength: 12)
HStack(spacing: 6) {
.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)
.fixedSize()
}
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(.tertiary)
}
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.padding(.horizontal)
.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,320 @@
import SwiftUI
// MARK: - ViewModel
@MainActor
class CommentsViewModel: ObservableObject {
let slug: String
@Published var comments: [BookComment] = []
@Published var myVotes: [String: String] = [:] // commentId "up" | "down"
@Published var isLoading = true
@Published var error: String?
@Published var newBody = ""
@Published var isPosting = false
@Published var postError: String?
private var votingIds: Set<String> = []
init(slug: String) {
self.slug = slug
}
func load() async {
isLoading = true
error = nil
do {
let response = try await APIClient.shared.fetchComments(slug: slug)
comments = response.comments
myVotes = response.myVotes
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func postComment() async {
let text = newBody.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !isPosting else { return }
if text.count > 2000 {
postError = "Comment too long (max 2000 characters)."
return
}
isPosting = true
postError = nil
do {
let created = try await APIClient.shared.postComment(slug: slug, body: text)
comments.insert(created, at: 0)
newBody = ""
} catch let apiError as APIError {
switch apiError {
case .httpError(401, _): postError = "You must be logged in to comment."
default: postError = apiError.localizedDescription
}
} catch {
postError = error.localizedDescription
}
isPosting = false
}
func vote(commentId: String, vote: String) async {
guard !votingIds.contains(commentId) else { return }
votingIds.insert(commentId)
defer { votingIds.remove(commentId) }
do {
let updated = try await APIClient.shared.voteComment(commentId: commentId, vote: vote)
// Update the comment in the list
if let idx = comments.firstIndex(where: { $0.id == commentId }) {
comments[idx] = updated
}
// Toggle myVotes
let prev = myVotes[commentId]
if prev == vote {
myVotes.removeValue(forKey: commentId)
} else {
myVotes[commentId] = vote
}
} catch {
// Silently ignore vote errors don't disrupt the UI
}
}
func isVoting(_ commentId: String) -> Bool {
votingIds.contains(commentId)
}
}
// MARK: - CommentsView
struct CommentsView: View {
@StateObject private var vm: CommentsViewModel
@EnvironmentObject private var authStore: AuthStore
init(slug: String) {
_vm = StateObject(wrappedValue: CommentsViewModel(slug: slug))
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Section header
HStack {
Text("Comments")
.font(.headline)
if !vm.isLoading && !vm.comments.isEmpty {
Text("(\(vm.comments.count))")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.horizontal)
.padding(.vertical, 14)
Divider().padding(.horizontal)
// Post form
postForm
.padding(.horizontal)
.padding(.vertical, 12)
Divider().padding(.horizontal)
// Comment list
if vm.isLoading {
loadingPlaceholder
} else if let err = vm.error {
Text(err)
.font(.subheadline)
.foregroundStyle(.red)
.padding()
} else if vm.comments.isEmpty {
Text("No comments yet. Be the first!")
.font(.subheadline)
.foregroundStyle(.secondary)
.padding()
} else {
ForEach(vm.comments) { comment in
CommentRow(
comment: comment,
myVote: vm.myVotes[comment.id],
isVoting: vm.isVoting(comment.id)
) { vote in
Task { await vm.vote(commentId: comment.id, vote: vote) }
}
Divider().padding(.leading, 16)
}
}
Color.clear.frame(height: 16)
}
.task { await vm.load() }
}
// MARK: - Post form
@ViewBuilder
private var postForm: some View {
VStack(alignment: .leading, spacing: 8) {
ZStack(alignment: .topLeading) {
if vm.newBody.isEmpty {
Text("Write a comment…")
.font(.subheadline)
.foregroundStyle(.tertiary)
.padding(.top, 8)
.padding(.leading, 4)
}
TextEditor(text: $vm.newBody)
.font(.subheadline)
.frame(minHeight: 72, maxHeight: 160)
.scrollContentBackground(.hidden)
}
.padding(10)
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
HStack {
let count = vm.newBody.count
Text("\(count)/2000")
.font(.caption2)
.monospacedDigit()
.foregroundStyle(count > 2000 ? .red : .tertiary)
Spacer()
if let err = vm.postError {
Text(err)
.font(.caption2)
.foregroundStyle(.red)
.lineLimit(1)
}
Button {
Task { await vm.postComment() }
} label: {
if vm.isPosting {
ProgressView().controlSize(.small)
} else {
Text("Post")
.fontWeight(.semibold)
}
}
.buttonStyle(.borderedProminent)
.tint(.amber)
.controlSize(.small)
.disabled(vm.isPosting || vm.newBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.newBody.count > 2000)
}
}
}
// MARK: - Loading skeleton
@ViewBuilder
private var loadingPlaceholder: some View {
VStack(spacing: 12) {
ForEach(0..<3, id: \.self) { _ in
VStack(alignment: .leading, spacing: 8) {
RoundedRectangle(cornerRadius: 4)
.fill(Color(.systemGray5))
.frame(width: 100, height: 12)
RoundedRectangle(cornerRadius: 4)
.fill(Color(.systemGray6))
.frame(maxWidth: .infinity)
.frame(height: 12)
RoundedRectangle(cornerRadius: 4)
.fill(Color(.systemGray6))
.frame(width: 200, height: 12)
}
.padding(.horizontal)
.redacted(reason: .placeholder)
}
}
.padding(.vertical, 12)
}
}
// MARK: - CommentRow
private struct CommentRow: View {
let comment: BookComment
let myVote: String?
let isVoting: Bool
let onVote: (String) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 6) {
// Username + date
HStack(spacing: 6) {
Text(comment.username.isEmpty ? "Anonymous" : comment.username)
.font(.subheadline.weight(.medium))
Text("·")
.foregroundStyle(.tertiary)
Text(formattedDate(comment.created))
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
}
// Body
Text(comment.body)
.font(.subheadline)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
// Vote row
HStack(spacing: 16) {
// Upvote
Button {
onVote("up")
} label: {
HStack(spacing: 4) {
Image(systemName: myVote == "up" ? "hand.thumbsup.fill" : "hand.thumbsup")
.font(.caption)
Text("\(comment.upvotes)")
.font(.caption.monospacedDigit())
}
.foregroundStyle(myVote == "up" ? Color.amber : .secondary)
}
.disabled(isVoting)
// Downvote
Button {
onVote("down")
} label: {
HStack(spacing: 4) {
Image(systemName: myVote == "down" ? "hand.thumbsdown.fill" : "hand.thumbsdown")
.font(.caption)
Text("\(comment.downvotes)")
.font(.caption.monospacedDigit())
}
.foregroundStyle(myVote == "down" ? .red : .secondary)
}
.disabled(isVoting)
Spacer()
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.opacity(isVoting ? 0.6 : 1)
}
private func formattedDate(_ iso: String) -> String {
// PocketBase returns "2006-01-02 15:04:05.999Z" format
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: iso) {
let rel = RelativeDateTimeFormatter()
rel.unitsStyle = .abbreviated
return rel.localizedString(for: date, relativeTo: Date())
}
// Fallback: try space-separated format
let df = DateFormatter()
df.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ"
if let date = df.date(from: iso) {
let rel = RelativeDateTimeFormatter()
rel.unitsStyle = .abbreviated
return rel.localizedString(for: date, relativeTo: Date())
}
return String(iso.prefix(10))
}
}

View File

@@ -29,13 +29,13 @@ struct BrowseView: View {
// Filter chips row
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
FilterChip(label: "Sort: \(vm.sort.capitalized)", isActive: vm.sort != "popular") {
ChipButton(label: "Sort: \(vm.sort.capitalized)", isSelected: vm.sort != "popular", style: .outlined) {
showFilters = true
}
FilterChip(label: "Genre: \(vm.genre == "all" ? "All" : vm.genre.capitalized)", isActive: vm.genre != "all") {
ChipButton(label: "Genre: \(vm.genre == "all" ? "All" : vm.genre.capitalized)", isSelected: vm.genre != "all", style: .outlined) {
showFilters = true
}
FilterChip(label: "Status: \(vm.status == "all" ? "All" : vm.status.capitalized)", isActive: vm.status != "all") {
ChipButton(label: "Status: \(vm.status == "all" ? "All" : vm.status.capitalized)", isSelected: vm.status != "all", style: .outlined) {
showFilters = true
}
}
@@ -72,6 +72,7 @@ struct BrowseView: View {
ForEach(vm.novels) { novel in
NavigationLink(value: NavDestination.book(novel.slug)) {
BrowseCard(novel: novel)
.bookCoverZoomSource(slug: novel.slug)
}
.buttonStyle(.plain)
}
@@ -90,12 +91,7 @@ struct BrowseView: View {
}
}
.navigationTitle("Discover")
.navigationDestination(for: NavDestination.self) { dest in
switch dest {
case .book(let slug): BookDetailView(slug: slug)
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
}
}
.appNavigationDestination()
.sheet(isPresented: $showFilters) {
BrowseFiltersView(vm: vm)
}
@@ -107,25 +103,6 @@ struct BrowseView: View {
}
}
// 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 {

View File

@@ -80,3 +80,64 @@ struct TagChip: View {
.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

@@ -1,5 +1,4 @@
import SwiftUI
import Kingfisher
struct HomeView: View {
@StateObject private var vm = HomeViewModel()
@@ -8,51 +7,58 @@ struct HomeView: View {
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 28) {
VStack(alignment: .leading, spacing: 0) {
// Stats bar
// 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 {
HStack(spacing: 0) {
StatCell(value: "\(stats.totalBooks)", label: "Books")
Divider().frame(height: 32)
StatCell(value: "\(stats.totalChapters)", label: "Chapters")
Divider().frame(height: 32)
StatCell(value: "\(stats.booksInProgress)", label: "In Progress")
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
StatsStrip(stats: stats)
.padding(.horizontal)
.padding(.bottom, 28)
}
// Continue reading
if !vm.continueReading.isEmpty {
SectionHeader(title: "Continue Reading")
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 12) {
ForEach(vm.continueReading) { item in
NavigationLink(value: NavDestination.book(item.book.slug)) {
ContinueReadingCard(item: item)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
}
}
// Recently updated
// Recently updated shelf
if !vm.recentlyUpdated.isEmpty {
SectionHeader(title: "Recently Updated")
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 16) {
ForEach(vm.recentlyUpdated) { book in
NavigationLink(value: NavDestination.book(book.slug)) {
BookCard(book: book)
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)
}
.buttonStyle(.plain)
}
.padding(.horizontal)
.padding(.bottom, 4)
}
.padding(.horizontal)
.padding(.bottom, 28)
}
// Empty state
@@ -71,78 +77,227 @@ struct HomeView: View {
.frame(maxWidth: .infinity)
.padding(.top, 60)
}
}
.padding(.vertical)
}
.navigationTitle("Home")
.navigationDestination(for: NavDestination.self) { dest in
switch dest {
case .book(let slug): BookDetailView(slug: slug)
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
Color.clear.frame(height: 20)
}
}
.navigationTitle("Reading Now")
.appNavigationDestination()
.refreshable { await vm.load() }
.task { await vm.load() }
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
Button("OK") { vm.error = nil }
} message: {
Text(vm.error ?? "")
}
.errorAlert($vm.error)
}
}
}
// MARK: - Supporting components
// MARK: - Hero card (full-width, Apple Books "Now Playing" style)
private struct HeroContinueCard: View {
let item: ContinueReadingItem
private struct StatCell: View {
let value: String
let label: String
var body: some View {
VStack(spacing: 2) {
Text(value).font(.title2.bold()).foregroundStyle(.primary)
Text(label).font(.caption).foregroundStyle(.secondary)
NavigationLink(value: NavDestination.chapter(item.book.slug, item.chapter)) {
ZStack(alignment: .bottomLeading) {
// Blurred background
AsyncCoverImage(url: item.book.cover, isBackground: true)
.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) {
AsyncCoverImage(url: item.book.cover)
.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)
}
.frame(maxWidth: .infinity)
.buttonStyle(.plain)
}
}
private struct SectionHeader: View {
// 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) {
KFImage(URL(string: item.book.cover))
.resizable()
.placeholder { coverPlaceholder }
.scaledToFill()
.frame(width: 120, height: 170)
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(alignment: .bottomTrailing) {
ZStack(alignment: .bottomTrailing) {
AsyncCoverImage(url: item.book.cover)
.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(.caption2.bold())
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(.ultraThinMaterial, in: Capsule())
.padding(6)
.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: 120, alignment: .leading)
.frame(width: 110, alignment: .leading)
}
}
private var coverPlaceholder: some View {
RoundedRectangle(cornerRadius: 10)
.fill(Color(.systemGray5))
.frame(width: 120, height: 170)
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
}
// MARK: - Horizontal shelf: recently updated book card
private struct ShelfBookCard: View {
let book: Book
var body: some View {
VStack(alignment: .leading, spacing: 6) {
AsyncCoverImage(url: book.cover)
.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

@@ -3,6 +3,83 @@ 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 {
@@ -18,69 +95,226 @@ struct LibraryView: View {
)
} else {
ScrollView {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 150), spacing: 12)],
spacing: 16
) {
ForEach(vm.items) { item in
NavigationLink(value: NavDestination.book(item.book.slug)) {
LibraryCard(item: item)
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)
}
}
.buttonStyle(.plain)
}
.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)
}
}
.padding()
}
}
}
.navigationTitle("Library")
.navigationDestination(for: NavDestination.self) { dest in
switch dest {
case .book(let slug): BookDetailView(slug: slug)
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
}
}
.appNavigationDestination()
.refreshable { await vm.load() }
.task { await vm.load() }
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
Button("OK") { vm.error = nil }
} message: { Text(vm.error ?? "") }
.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."
}
}
}
private struct LibraryCard: View {
// 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: .bottomTrailing) {
ZStack(alignment: .topTrailing) {
// Cover image
KFImage(URL(string: item.book.cover))
.resizable()
.placeholder {
RoundedRectangle(cornerRadius: 10)
RoundedRectangle(cornerRadius: 8)
.fill(Color(.systemGray5))
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
.overlay(
Image(systemName: "book.closed")
.foregroundStyle(.secondary)
)
}
.scaledToFill()
.frame(height: 200)
.clipShape(RoundedRectangle(cornerRadius: 10))
.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)
if let ch = item.lastChapter {
Text("Ch.\(ch)")
.font(.caption2.bold())
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(.ultraThinMaterial, in: Capsule())
.padding(6)
// 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)
Text(item.book.author)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.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,161 @@
import SwiftUI
// MARK: - AvatarCropView
// A sheet that lets the user pan and pinch a photo to fill a 1:1 square crop region.
// Call: .sheet(item: $cropImage) { AvatarCropView(image: $0.image, onConfirm: { croppedData in }) }
struct AvatarCropView: View {
let image: UIImage
let onConfirm: (Data) -> Void
let onCancel: () -> Void
// Crop square side length (points) matched to the web 400 px target
private let cropSize: CGFloat = 280
// Pan/zoom state
@State private var scale: CGFloat = 1.0
@State private var lastScale: CGFloat = 1.0
@State private var offset: CGSize = .zero
@State private var lastOffset: CGSize = .zero
var body: some View {
NavigationStack {
GeometryReader { geo in
ZStack {
Color.black.ignoresSafeArea()
// Draggable / pinchable image
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: geo.size.width, height: geo.size.height)
.scaleEffect(scale)
.offset(offset)
.gesture(
SimultaneousGesture(
MagnificationGesture()
.onChanged { value in
scale = max(1.0, lastScale * value)
}
.onEnded { _ in
lastScale = scale
},
DragGesture()
.onChanged { value in
offset = CGSize(
width: lastOffset.width + value.translation.width,
height: lastOffset.height + value.translation.height
)
}
.onEnded { _ in
lastOffset = offset
}
)
)
.clipped()
// Dim overlay with transparent crop square cut out
CropOverlay(cropSize: cropSize, containerSize: geo.size)
.allowsHitTesting(false)
}
}
.navigationTitle("Crop Photo")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel", action: onCancel)
.foregroundStyle(.white)
}
ToolbarItem(placement: .topBarTrailing) {
Button("Use Photo") {
confirmCrop()
}
.fontWeight(.semibold)
.foregroundStyle(.amber)
}
}
.toolbarColorScheme(.dark, for: .navigationBar)
}
.onAppear { fitImageInitially() }
}
// MARK: - Crop
private func fitImageInitially() {
// Scale image so its shorter dimension fills the crop square
let imgAspect = image.size.width / image.size.height
if imgAspect > 1 {
// wider than tall fit height to cropSize
scale = cropSize / image.size.height * (image.size.height / image.size.width)
} else {
scale = 1.0
}
scale = max(1.0, scale)
lastScale = scale
}
private func confirmCrop() {
// Render image at current pan/zoom into a 400×400 bitmap
let outputSize = CGSize(width: 400, height: 400)
let renderer = UIGraphicsImageRenderer(size: outputSize)
let cropped = renderer.image { ctx in
// We need to map from the SwiftUI transform back to image pixels.
// We render the raw UIImage into the output rect, applying the same
// scale / offset proportionally (normalised by crop square / container).
let screenCropSize: CGFloat = cropSize
// Scale factor: pixels per SwiftUI point in the output
let outputScale = outputSize.width / screenCropSize
ctx.cgContext.translateBy(x: outputSize.width / 2, y: outputSize.height / 2)
ctx.cgContext.scaleBy(x: scale * outputScale, y: scale * outputScale)
ctx.cgContext.translateBy(
x: -image.size.width / 2 + (offset.width * outputScale / scale),
y: -image.size.height / 2 + (offset.height * outputScale / scale)
)
image.draw(at: .zero)
}
if let jpeg = cropped.jpegData(compressionQuality: 0.9) {
onConfirm(jpeg)
}
}
}
// MARK: - Crop overlay
private struct CropOverlay: View {
let cropSize: CGFloat
let containerSize: CGSize
var body: some View {
Canvas { context, size in
// Fill entire canvas with semi-transparent black
context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(.black.opacity(0.55)))
// Cut out the crop square in the centre
let origin = CGPoint(
x: (size.width - cropSize) / 2,
y: (size.height - cropSize) / 2
)
let cropRect = CGRect(origin: origin, size: CGSize(width: cropSize, height: cropSize))
context.blendMode = .destinationOut
context.fill(Path(ellipseIn: cropRect), with: .color(.white))
}
.compositingGroup()
.overlay {
// Amber circle border around the crop region
let origin = CGPoint(
x: (containerSize.width - cropSize) / 2,
y: (containerSize.height - cropSize) / 2
)
Circle()
.stroke(Color.amber.opacity(0.8), lineWidth: 2)
.frame(width: cropSize, height: cropSize)
.position(
x: origin.x + cropSize / 2,
y: origin.y + cropSize / 2
)
}
.frame(width: containerSize.width, height: containerSize.height)
.allowsHitTesting(false)
}
}

View File

@@ -1,31 +1,100 @@
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 pendingCropImage: UIImage? // image waiting to be cropped
@State private var avatarURL: String? = nil
@State private var avatarUploading = false
@State private var avatarError: String?
var body: some View {
NavigationStack {
List {
// User header
// User header
Section {
HStack(spacing: 14) {
Image(systemName: "person.circle.fill")
.font(.system(size: 48))
.foregroundStyle(.amber)
VStack(alignment: .leading, spacing: 2) {
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 loadImageForCrop(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
// Reading settings
Section("Reading Settings") {
voicePicker
speedSlider
@@ -42,7 +111,7 @@ struct ProfileView: View {
.tint(.amber)
}
// Sessions
// Sessions
Section("Active Sessions") {
if vm.sessionsLoading {
ProgressView()
@@ -55,7 +124,7 @@ struct ProfileView: View {
}
}
// Account
// Account
Section("Account") {
Button("Change Password") { showChangePassword = true }
Button("Sign Out", role: .destructive) {
@@ -64,13 +133,51 @@ struct ProfileView: View {
}
}
.navigationTitle("Profile")
.task { await vm.loadSessions() }
.task {
await vm.loadSessions()
}
.sheet(isPresented: $showChangePassword) {
ChangePasswordView()
}
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
Button("OK") { vm.error = nil }
} message: { Text(vm.error ?? "") }
.sheet(item: Binding(
get: { pendingCropImage.map { CropImageItem(image: $0) } },
set: { if $0 == nil { pendingCropImage = nil } }
)) { item in
AvatarCropView(image: item.image) { croppedData in
pendingCropImage = nil
Task { await uploadCroppedData(croppedData) }
} onCancel: {
pendingCropImage = nil
}
}
.errorAlert($vm.error)
}
}
// MARK: - Avatar upload
/// Step 1: Load the raw image from the picker and show the crop sheet.
private func loadImageForCrop(_ item: PhotosPickerItem) async {
guard let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) else {
avatarError = "Could not read image"
return
}
pendingCropImage = image
}
/// Step 2: Called by AvatarCropView once the user confirms. Upload the cropped JPEG.
private func uploadCroppedData(_ data: Data) async {
avatarUploading = true
avatarError = nil
defer { avatarUploading = false }
do {
let url = try await APIClient.shared.uploadAvatar(data, mimeType: "image/jpeg")
avatarURL = url
// Refresh user record so the new avatar persists across sessions
await authStore.validateToken()
} catch {
avatarError = "Upload failed: \(error.localizedDescription)"
}
}
@@ -121,7 +228,7 @@ struct ProfileView: View {
}
}
),
in: 0.5...3.0, step: 0.25
in: 0.5...2.0, step: 0.25
)
.tint(.amber)
}
@@ -208,7 +315,8 @@ struct ChangePasswordView: View {
body: Body(currentPassword: current, newPassword: newPwd)
)
success = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { dismiss() }
try? await Task.sleep(nanoseconds: 1_200_000_000)
dismiss()
} catch {
self.error = error.localizedDescription
}
@@ -216,3 +324,10 @@ struct ChangePasswordView: View {
}
}
}
// MARK: - Crop image item (Identifiable wrapper for .sheet(item:))
private struct CropImageItem: Identifiable {
let id = UUID()
let image: UIImage
}

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

View File

@@ -1,6 +1,6 @@
name: LibNovel
options:
bundleIdPrefix: cc.kalekber
bundleIdPrefix: com.kalekber
deploymentTarget:
iOS: "17.0"
xcodeVersion: "16.0"
@@ -23,10 +23,6 @@ settings:
SWIFT_ACTIVE_COMPILATION_CONDITIONS: ""
packages:
# Markdown rendering — renders chapter HTML from the server
MarkdownUI:
url: https://github.com/gonzalezreal/swift-markdown-ui
from: "2.4.0"
# Async image loading with caching
Kingfisher:
url: https://github.com/onevcat/Kingfisher
@@ -46,14 +42,19 @@ targets:
- path: LibNovel/Resources/Assets.xcassets
dependencies:
- package: Kingfisher
- package: MarkdownUI
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: cc.kalekber.libnovel
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
@@ -65,7 +66,7 @@ targets:
- target: LibNovel
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: cc.kalekber.libnovel.tests
PRODUCT_BUNDLE_IDENTIFIER: com.kalekber.LibNovel.tests
schemes:
LibNovel:

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

@@ -130,13 +130,18 @@ ios-test: ios-gen ios-resolve
# 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.
ios-archive: ios-gen ios-resolve
# 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
-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.
@@ -147,6 +152,23 @@ ios-export:
-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

View File

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

@@ -165,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)

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 {

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

@@ -16,6 +16,9 @@
// 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)
// book_comments — slug(text), user_id(text), username(text), body(text),
// upvotes(number), downvotes(number), created(date)
// comment_votes — comment_id(text), user_id(text), session_id(text), vote(text: up|down)
package storage
import (
@@ -422,6 +425,29 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error {
{"name": "last_seen", "type": "date"},
},
},
{
"name": "book_comments",
"type": "base",
"fields": []map[string]interface{}{
{"name": "slug", "type": "text", "required": true},
{"name": "user_id", "type": "text"},
{"name": "username", "type": "text"},
{"name": "body", "type": "text", "required": true},
{"name": "upvotes", "type": "number"},
{"name": "downvotes", "type": "number"},
{"name": "created", "type": "date"},
},
},
{
"name": "comment_votes",
"type": "base",
"fields": []map[string]interface{}{
{"name": "comment_id", "type": "text", "required": true},
{"name": "user_id", "type": "text"},
{"name": "session_id", "type": "text", "required": true},
{"name": "vote", "type": "text", "required": true}, // "up" | "down"
},
},
}
for _, col := range collections {
name, _ := col["name"].(string)
@@ -458,6 +484,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

View File

@@ -160,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.

13
scripts/.runner Normal file
View File

@@ -0,0 +1,13 @@
{
"WARNING": "This file is automatically generated by act-runner. Do not edit it manually unless you know what you are doing. Removing this file will cause act runner to re-register as a new runner.",
"id": 11,
"uuid": "d5d04e0a-572c-46c0-83be-405508948391",
"name": "runner-mac-1",
"token": "ddf214ce148b4673a186f29cb684b407cb8c2ecc",
"address": "https://gitea.kalekber.cc/",
"labels": [
"macos-latest:host",
"macos-14:host"
],
"ephemeral": false
}

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,31 @@ 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"
create_collection "book_comments" '{
"name": "book_comments",
"type": "base",
"fields": [
{"name": "slug", "type": "text", "required": true},
{"name": "user_id", "type": "text"},
{"name": "username", "type": "text"},
{"name": "body", "type": "text", "required": true},
{"name": "upvotes", "type": "number"},
{"name": "downvotes", "type": "number"},
{"name": "created", "type": "date"}
]
}'
create_collection "comment_votes" '{
"name": "comment_votes",
"type": "base",
"fields": [
{"name": "comment_id", "type": "text", "required": true},
{"name": "user_id", "type": "text"},
{"name": "session_id", "type": "text", "required": true},
{"name": "vote", "type": "text", "required": true}
]
}'
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! ==="

View File

@@ -8,16 +8,11 @@ COPY . .
RUN npm run build
# ── Runtime image ──────────────────────────────────────────────────────────────
# adapter-node bundles all dependencies into build/ — no npm install needed.
FROM node:22-alpine
WORKDIR /app
# adapter-node produces a standalone build/
COPY --from=builder /app/build ./build
COPY --from=builder /app/package.json ./
COPY --from=builder /app/package-lock.json ./
# Install production dependencies (e.g. marked) that are imported at runtime
RUN npm ci --omit=dev
ENV NODE_ENV=production
ENV PORT=3000
@@ -25,3 +20,4 @@ ENV HOST=0.0.0.0
EXPOSE $PORT
CMD ["node", "build"]

1680
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,92 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import Cropper from 'cropperjs';
import 'cropperjs/dist/cropper.css';
interface Props {
file: File;
onconfirm: (blob: Blob, mimeType: string) => void;
oncancel: () => void;
}
let { file, onconfirm, oncancel }: Props = $props();
let imgEl: HTMLImageElement;
let cropper: Cropper | null = null;
let objectUrl = $state('');
onMount(() => {
objectUrl = URL.createObjectURL(file);
cropper = new Cropper(imgEl, {
aspectRatio: 1,
viewMode: 1,
dragMode: 'move',
autoCropArea: 0.8,
restore: false,
guides: false,
center: true,
highlight: false,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: false,
background: false
});
});
onDestroy(() => {
cropper?.destroy();
URL.revokeObjectURL(objectUrl);
});
function confirm() {
if (!cropper) return;
const canvas = cropper.getCroppedCanvas({ width: 400, height: 400 });
const mimeType = file.type === 'image/webp' ? 'image/webp' : 'image/jpeg';
canvas.toBlob(
(blob: Blob | null) => {
if (blob) onconfirm(blob, mimeType);
},
mimeType,
0.9
);
}
</script>
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
aria-label="Crop profile picture"
>
<div class="bg-zinc-900 rounded-2xl border border-zinc-700 shadow-2xl w-full max-w-sm flex flex-col gap-4 p-5">
<h2 class="text-base font-semibold text-zinc-100">Crop profile picture</h2>
<!-- Cropper image container -->
<div class="rounded-xl overflow-hidden bg-zinc-800" style="max-height: 340px;">
<img
bind:this={imgEl}
src={objectUrl}
alt="Crop preview"
style="display:block; max-width:100%;"
/>
</div>
<p class="text-xs text-zinc-500 text-center">Drag to reposition · pinch or scroll to zoom · drag corners to resize</p>
<div class="flex gap-3">
<button
onclick={oncancel}
class="flex-1 py-2 rounded-lg border border-zinc-600 text-zinc-300 text-sm font-medium hover:bg-zinc-700 transition-colors"
>
Cancel
</button>
<button
onclick={confirm}
class="flex-1 py-2 rounded-lg bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors"
>
Use photo
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,254 @@
<script lang="ts">
interface BookComment {
id: string;
slug: string;
user_id: string;
username: string;
body: string;
upvotes: number;
downvotes: number;
created: string;
}
let {
slug,
isLoggedIn = false
}: {
slug: string;
isLoggedIn?: boolean;
} = $props();
// ── State ─────────────────────────────────────────────────────────────────
let comments = $state<BookComment[]>([]);
let myVotes = $state<Record<string, 'up' | 'down'>>({});
let loading = $state(true);
let loadError = $state('');
let newBody = $state('');
let posting = $state(false);
let postError = $state('');
// Per-comment vote inflight set (prevents double-clicks)
let votingIds = $state(new Set<string>());
// ── Load comments on mount ────────────────────────────────────────────────
async function loadComments() {
loading = true;
loadError = '';
try {
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`);
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json();
comments = data.comments ?? [];
myVotes = data.myVotes ?? {};
} catch (e) {
loadError = 'Failed to load comments.';
} finally {
loading = false;
}
}
// Run once on component mount via $effect
$effect(() => {
loadComments();
});
// ── Post comment ──────────────────────────────────────────────────────────
async function postComment() {
const text = newBody.trim();
if (!text || posting) return;
if (text.length > 2000) {
postError = 'Comment is too long (max 2000 characters).';
return;
}
posting = true;
postError = '';
try {
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: text })
});
if (res.status === 401) {
postError = 'You must be logged in to comment.';
return;
}
if (!res.ok) {
const err = await res.json().catch(() => ({}));
postError = err.message ?? 'Failed to post comment.';
return;
}
const created: BookComment = await res.json();
comments = [created, ...comments];
newBody = '';
} catch {
postError = 'Failed to post comment.';
} finally {
posting = false;
}
}
// ── Vote ──────────────────────────────────────────────────────────────────
async function vote(commentId: string, v: 'up' | 'down') {
if (votingIds.has(commentId)) return;
votingIds = new Set([...votingIds, commentId]);
try {
const res = await fetch(`/api/comments/${commentId}/vote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vote: v })
});
if (!res.ok) return;
const updated: BookComment = await res.json();
// Update comment in list
comments = comments.map((c) => (c.id === commentId ? updated : c));
// Update myVotes: toggle off if same, else set new vote
const prev = myVotes[commentId];
if (prev === v) {
const next = { ...myVotes };
delete next[commentId];
myVotes = next;
} else {
myVotes = { ...myVotes, [commentId]: v };
}
} finally {
const next = new Set(votingIds);
next.delete(commentId);
votingIds = next;
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch {
return iso;
}
}
const charCount = $derived(newBody.length);
const charOver = $derived(charCount > 2000);
</script>
<div class="mt-10">
<h2 class="text-base font-semibold text-zinc-200 mb-4">
Comments
{#if !loading && comments.length > 0}
<span class="text-zinc-500 font-normal text-sm ml-1">({comments.length})</span>
{/if}
</h2>
<!-- Post form -->
<div class="mb-6">
{#if isLoggedIn}
<div class="flex flex-col gap-2">
<textarea
bind:value={newBody}
placeholder="Write a comment…"
rows="3"
class="w-full px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-zinc-200 text-sm placeholder-zinc-500 resize-none focus:outline-none focus:border-amber-400 transition-colors"
></textarea>
<div class="flex items-center justify-between gap-3">
<span class="text-xs {charOver ? 'text-red-400' : 'text-zinc-600'} tabular-nums">
{charCount}/2000
</span>
<div class="flex items-center gap-3">
{#if postError}
<span class="text-xs text-red-400">{postError}</span>
{/if}
<button
onclick={postComment}
disabled={posting || !newBody.trim() || charOver}
class="px-4 py-1.5 rounded-lg text-sm font-medium transition-colors
{posting || !newBody.trim() || charOver
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300'}"
>
{posting ? 'Posting…' : 'Post'}
</button>
</div>
</div>
</div>
{:else}
<p class="text-sm text-zinc-500">
<a href="/auth/login" class="text-amber-400 hover:text-amber-300 transition-colors">Log in</a>
to leave a comment.
</p>
{/if}
</div>
<!-- Comment list -->
{#if loading}
<div class="flex flex-col gap-3">
{#each Array(3) as _}
<div class="rounded-lg bg-zinc-800/50 p-4 animate-pulse">
<div class="h-3 w-24 bg-zinc-700 rounded mb-3"></div>
<div class="h-3 w-full bg-zinc-700/60 rounded mb-2"></div>
<div class="h-3 w-3/4 bg-zinc-700/60 rounded"></div>
</div>
{/each}
</div>
{:else if loadError}
<p class="text-sm text-red-400">{loadError}</p>
{:else if comments.length === 0}
<p class="text-sm text-zinc-500">No comments yet. Be the first!</p>
{:else}
<div class="flex flex-col gap-3">
{#each comments as comment (comment.id)}
{@const myVote = myVotes[comment.id]}
{@const voting = votingIds.has(comment.id)}
<div class="rounded-lg bg-zinc-800/50 border border-zinc-700/50 px-4 py-3 flex flex-col gap-2">
<!-- Header: username + date -->
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium text-zinc-200">{comment.username || 'Anonymous'}</span>
<span class="text-zinc-600 text-xs">&middot;</span>
<span class="text-xs text-zinc-500">{formatDate(comment.created)}</span>
</div>
<!-- Body -->
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
<!-- Vote row -->
<div class="flex items-center gap-3 pt-1">
<!-- Upvote -->
<button
onclick={() => vote(comment.id, 'up')}
disabled={voting}
title="Upvote"
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
{myVote === 'up'
? 'text-amber-400'
: 'text-zinc-500 hover:text-zinc-300'}"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
</svg>
<span class="tabular-nums">{comment.upvotes ?? 0}</span>
</button>
<!-- Downvote -->
<button
onclick={() => vote(comment.id, 'down')}
disabled={voting}
title="Downvote"
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
{myVote === 'down'
? 'text-red-400'
: 'text-zinc-500 hover:text-zinc-300'}"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
</svg>
<span class="tabular-nums">{comment.downvotes ?? 0}</span>
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>

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 ─────────────────────────────────────────────────────────
@@ -794,3 +795,200 @@ export async function revokeAllUserSessions(userId: string): Promise<void> {
)
);
}
/**
* 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}`);
}
}
// ─── Comments ─────────────────────────────────────────────────────────────────
export interface BookComment {
id: string;
slug: string;
user_id: string;
username: string;
body: string;
upvotes: number;
downvotes: number;
created: string;
}
export interface CommentVote {
id: string;
comment_id: string;
user_id: string;
session_id: string;
vote: 'up' | 'down';
}
/**
* List comments for a book, newest first, up to 100.
*/
export async function listComments(slug: string): Promise<BookComment[]> {
const token = await getToken();
const filter = encodeURIComponent(`slug="${slug.replace(/"/g, '\\"')}"`);
const res = await fetch(
`${PB_URL}/api/collections/book_comments/records?filter=${filter}&sort=-created&perPage=100`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) return [];
const data = await res.json();
return (data.items ?? []) as BookComment[];
}
/**
* Create a new comment. Returns the created record.
*/
export async function createComment(
slug: string,
body: string,
userId: string | undefined,
username: string
): Promise<BookComment> {
const token = await getToken();
const res = await fetch(`${PB_URL}/api/collections/book_comments/records`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
slug,
body,
user_id: userId ?? '',
username,
upvotes: 0,
downvotes: 0,
created: new Date().toISOString()
})
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`createComment failed: ${res.status} ${text}`);
}
return res.json() as Promise<BookComment>;
}
/**
* Get an existing vote by this voter (identified by user_id or session_id) on a comment.
*/
export async function getCommentVote(
commentId: string,
sessionId: string,
userId?: string
): Promise<CommentVote | null> {
const token = await getToken();
const voterFilter = userId
? `comment_id="${commentId}"&&user_id="${userId}"`
: `comment_id="${commentId}"&&session_id="${sessionId}"`;
const res = await fetch(
`${PB_URL}/api/collections/comment_votes/records?filter=${encodeURIComponent(voterFilter)}&perPage=1`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) return null;
const data = await res.json();
const items = (data.items ?? []) as CommentVote[];
return items[0] ?? null;
}
/**
* Cast or change a vote on a comment. Handles:
* - New vote: creates vote record, increments counter.
* - Same vote again: removes it (toggle off), decrements counter.
* - Changed vote: updates record, adjusts both counters.
* Returns the updated comment.
*/
export async function voteComment(
commentId: string,
vote: 'up' | 'down',
sessionId: string,
userId?: string
): Promise<BookComment> {
const token = await getToken();
// Fetch current comment
const commentRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!commentRes.ok) throw new Error(`Comment not found: ${commentId}`);
const comment = (await commentRes.json()) as BookComment;
const existing = await getCommentVote(commentId, sessionId, userId);
let upDelta = 0;
let downDelta = 0;
if (!existing) {
// New vote
await fetch(`${PB_URL}/api/collections/comment_votes/records`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ comment_id: commentId, user_id: userId ?? '', session_id: sessionId, vote })
});
vote === 'up' ? upDelta++ : downDelta++;
} else if (existing.vote === vote) {
// Toggle off — remove vote
await fetch(`${PB_URL}/api/collections/comment_votes/records/${existing.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
});
vote === 'up' ? upDelta-- : downDelta--;
} else {
// Changed vote
await fetch(`${PB_URL}/api/collections/comment_votes/records/${existing.id}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ vote })
});
if (vote === 'up') { upDelta++; downDelta--; }
else { upDelta--; downDelta++; }
}
// Patch comment counters
const patchRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
upvotes: Math.max(0, (comment.upvotes ?? 0) + upDelta),
downvotes: Math.max(0, (comment.downvotes ?? 0) + downDelta)
})
});
if (!patchRes.ok) throw new Error(`Failed to update vote counts on comment ${commentId}`);
return patchRes.json() as Promise<BookComment>;
}
/**
* Fetch votes cast by this session/user, keyed by comment_id.
* Returns a map of commentId → 'up' | 'down'.
*/
export async function getMyVotes(
commentIds: string[],
sessionId: string,
userId?: string
): Promise<Record<string, 'up' | 'down'>> {
if (commentIds.length === 0) return {};
const token = await getToken();
const idFilter = commentIds.map((id) => `comment_id="${id}"`).join('||');
const voterPart = userId ? `user_id="${userId}"` : `session_id="${sessionId}"`;
const filter = encodeURIComponent(`(${idFilter})&&${voterPart}`);
const res = await fetch(
`${PB_URL}/api/collections/comment_votes/records?filter=${filter}&perPage=200`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) return {};
const data = await res.json();
const map: Record<string, 'up' | 'down'> = {};
for (const v of (data.items ?? []) as CommentVote[]) {
map[v.comment_id] = v.vote as 'up' | 'down';
}
return map;
}

View File

@@ -1,5 +1,6 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getUserByUsername } from '$lib/server/pocketbase';
/**
* GET /api/auth/me
@@ -10,10 +11,12 @@ 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,
created: locals.user.created ?? ''
avatar_url: record?.avatar_url ?? null
});
};

View File

@@ -0,0 +1,33 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { voteComment } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* POST /api/comments/[id]/vote
* Body: { vote: 'up' | 'down' }
* Casts, changes, or toggles off a vote on a comment.
* Works for both authenticated and anonymous users (session-scoped).
* Returns the updated comment.
*/
export const POST: RequestHandler = async ({ params, request, locals }) => {
const { id } = params;
let body: { vote?: string };
try {
body = await request.json();
} catch {
error(400, 'Invalid JSON body');
}
if (body.vote !== 'up' && body.vote !== 'down') {
error(400, 'vote must be "up" or "down"');
}
try {
const updated = await voteComment(id, body.vote, locals.sessionId, locals.user?.id);
return json(updated);
} catch (e) {
log.error('api/comments/[id]/vote', 'voteComment failed', { id, err: String(e) });
error(500, 'Failed to record vote');
}
};

View File

@@ -0,0 +1,54 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { listComments, createComment, getMyVotes } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* GET /api/comments/[slug]
* Returns comments for a book + the current visitor's votes.
* Response: { comments: BookComment[], myVotes: Record<string, 'up'|'down'> }
*/
export const GET: RequestHandler = async ({ params, locals }) => {
const { slug } = params;
try {
const comments = await listComments(slug);
const myVotes = await getMyVotes(
comments.map((c) => c.id),
locals.sessionId,
locals.user?.id
);
return json({ comments, myVotes });
} catch (e) {
log.error('api/comments/[slug]', 'listComments failed', { slug, err: String(e) });
error(500, 'Failed to load comments');
}
};
/**
* POST /api/comments/[slug]
* Body: { body: string }
* Creates a new comment. Requires authentication.
*/
export const POST: RequestHandler = async ({ params, request, locals }) => {
if (!locals.user) error(401, 'Login required to comment');
const { slug } = params;
let body: { body?: string };
try {
body = await request.json();
} catch {
error(400, 'Invalid JSON body');
}
const text = (body.body ?? '').trim();
if (!text) error(400, 'Comment body is required');
if (text.length > 2000) error(400, 'Comment is too long (max 2000 characters)');
try {
const comment = await createComment(slug, text, locals.user.id, locals.user.username);
return json(comment, { status: 201 });
} catch (e) {
log.error('api/comments/[slug]', 'createComment failed', { slug, err: String(e) });
error(500, 'Failed to post comment');
}
};

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

@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import CommentsSection from '$lib/components/CommentsSection.svelte';
let { data }: { data: PageData } = $props();
@@ -525,3 +526,6 @@
</div>
{/if}
</div>
<!-- ══════════════════════════════════════════════════ Comments ══ -->
<CommentsSection slug={data.book.slug} isLoggedIn={true} />

View File

@@ -6,6 +6,10 @@
let { data }: { data: PageData } = $props();
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;
@@ -15,10 +19,6 @@
const wordCount = $derived(countWords(html));
let html = $state(data.html);
let fetchingContent = $state(!data.isPreview && !data.html);
let fetchError = $state('');
onMount(async () => {
// Record reading progress (skip for preview chapters)
if (!data.isPreview) {

View File

@@ -0,0 +1,6 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = () => {
return json({ status: 'ok' });
};

View File

@@ -1,6 +1,7 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { changePassword, listUserSessions } from '$lib/server/pocketbase';
import { changePassword, listUserSessions, getUserByUsername } from '$lib/server/pocketbase';
import { presignAvatarUrl } from '$lib/server/minio';
import { log } from '$lib/server/logger';
export const load: PageServerLoad = async ({ locals }) => {
@@ -15,8 +16,20 @@ export const load: PageServerLoad = async ({ locals }) => {
log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) });
}
// Fetch avatar presigned URL if user has one
let avatarUrl: string | null = null;
try {
const record = await getUserByUsername(locals.user.username);
if (record?.avatar_url) {
avatarUrl = await presignAvatarUrl(locals.user.id);
}
} catch (e) {
log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(e) });
}
return {
user: locals.user,
avatarUrl,
sessions: sessions.map((s) => ({
id: s.id,
user_agent: s.user_agent,

View File

@@ -3,9 +3,81 @@
import { invalidateAll } from '$app/navigation';
import type { PageData, ActionData } from './$types';
import { audioStore } from '$lib/audio.svelte';
import { browser } from '$app/environment';
let { data, form }: { data: PageData; form: ActionData } = $props();
// ── Avatar ───────────────────────────────────────────────────────────────────
let avatarUrl = $state<string | null>(data.avatarUrl ?? null);
let avatarUploading = $state(false);
let avatarError = $state('');
let fileInput: HTMLInputElement | null = null;
// Crop modal state
let cropFile = $state<File | null>(null);
function handleAvatarChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
// Reset input so the same file can be re-selected after cancel
if (fileInput) fileInput.value = '';
cropFile = file;
}
async function handleCropConfirm(blob: Blob, mimeType: string) {
cropFile = null;
avatarUploading = true;
avatarError = '';
try {
// Step 1: get presigned PUT URL
const presignRes = await fetch('/api/profile/avatar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mime_type: mimeType })
});
if (!presignRes.ok) {
const body = await presignRes.json().catch(() => ({})) as { message?: string };
avatarError = body.message ?? `Failed to prepare upload (${presignRes.status})`;
return;
}
const { upload_url, key } = await presignRes.json() as { upload_url: string; key: string };
// Step 2: PUT blob directly to MinIO
const putRes = await fetch(upload_url, {
method: 'PUT',
headers: { 'Content-Type': mimeType },
body: blob
});
if (!putRes.ok) {
avatarError = `Upload failed (${putRes.status})`;
return;
}
// Step 3: record key in PocketBase and get fresh presigned GET URL
const patchRes = await fetch('/api/profile/avatar', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key })
});
if (!patchRes.ok) {
const body = await patchRes.json().catch(() => ({})) as { message?: string };
avatarError = body.message ?? `Failed to save avatar (${patchRes.status})`;
return;
}
const result = await patchRes.json() as { avatar_url: string | null };
avatarUrl = result.avatar_url;
} catch {
avatarError = 'Network error during upload';
} finally {
avatarUploading = false;
}
}
function handleCropCancel() {
cropFile = null;
}
// ── Settings ────────────────────────────────────────────────────────────────
let voices = $state<string[]>([]);
let voicesLoaded = $state(false);
@@ -141,13 +213,71 @@
<title>Profile — libnovel</title>
</svelte:head>
{#if cropFile && browser}
{#await import('$lib/components/AvatarCropModal.svelte') then { default: AvatarCropModal }}
<AvatarCropModal
file={cropFile}
onconfirm={handleCropConfirm}
oncancel={handleCropCancel}
/>
{/await}
{/if}
<!-- Hidden logout form used when user ends their own session -->
<form id="logout-form" method="POST" action="/logout" class="hidden"></form>
<div class="max-w-xl mx-auto space-y-10">
<div>
<h1 class="text-2xl font-bold text-zinc-100">Profile</h1>
<p class="text-zinc-400 text-sm mt-1">Signed in as <span class="text-zinc-200 font-medium">{data.user.username}</span></p>
<div class="flex items-center gap-5">
<!-- Avatar -->
<div class="relative shrink-0">
<button
onclick={() => fileInput?.click()}
class="group relative w-20 h-20 rounded-full overflow-hidden ring-2 ring-zinc-600 hover:ring-amber-400 transition-all focus:outline-none focus:ring-amber-400"
title="Change profile picture"
disabled={avatarUploading}
>
{#if avatarUrl}
<img src={avatarUrl} alt="Profile" class="w-full h-full object-cover" />
{:else}
<div class="w-full h-full bg-zinc-700 flex items-center justify-center">
<svg class="w-10 h-10 text-zinc-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/>
</svg>
</div>
{/if}
<!-- Hover overlay -->
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
{#if avatarUploading}
<svg class="w-5 h-5 text-white 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-8v8H4z"></path>
</svg>
{:else}
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
{/if}
</div>
</button>
<input
bind:this={fileInput}
type="file"
accept="image/jpeg,image/png,image/webp"
class="hidden"
onchange={handleAvatarChange}
/>
</div>
<div>
<h1 class="text-2xl font-bold text-zinc-100">{data.user.username}</h1>
<p class="text-zinc-400 text-sm mt-0.5 capitalize">{data.user.role}</p>
{#if avatarError}
<p class="text-red-400 text-xs mt-1">{avatarError}</p>
{:else}
<p class="text-zinc-500 text-xs mt-1">Click avatar to change photo</p>
{/if}
</div>
</div>
<!-- ── Reading settings ─────────────────────────────────────────────────── -->