Compare commits

..

97 Commits

Author SHA1 Message Date
Admin
b11f4ab6b4 fix: missing closing brace in setProgress function
Some checks failed
CI / Scraper / Lint (pull_request) Failing after 11s
CI / Scraper / Test (pull_request) Successful in 21s
CI / UI / Build (push) Successful in 24s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 17s
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (push) Successful in 32s
iOS CI / Build (pull_request) Successful in 1m58s
iOS CI / Test (pull_request) Successful in 5m4s
Release / Scraper / Test (push) Failing after 17s
Release / Scraper / Docker (push) Has been skipped
Release / UI / Build (push) Successful in 24s
Release / UI / Docker (push) Successful in 1m12s
2026-03-11 15:49:36 +05:00
Admin
3e4b1c0484 feat: add user profile views and library management
Some checks failed
CI / UI / Build (push) Failing after 11s
CI / Scraper / Lint (pull_request) Failing after 15s
CI / UI / Docker Push (push) Has been skipped
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Failing after 17s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 3m40s
iOS CI / Build (pull_request) Successful in 1m49s
iOS CI / Test (push) Successful in 4m24s
iOS CI / Test (pull_request) Successful in 4m46s
- Add UserProfileView and UserProfileViewModel for iOS
- Implement user library API endpoint (/api/users/[username]/library)
- Add DELETE /api/progress/[slug] endpoint for removing books from library
- Integrate subscription feed in home API
- Update Xcode project with new profile components
2026-03-11 15:45:04 +05:00
Admin
b5bc6ff3de feat: user profiles, subscriptions, and subscription feed
Some checks failed
CI / Scraper / Lint (push) Failing after 8s
CI / Scraper / Test (push) Successful in 11s
CI / Scraper / Lint (pull_request) Failing after 7s
CI / Scraper / Test (pull_request) Successful in 9s
CI / Scraper / Docker Push (push) Has been skipped
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (push) Successful in 23s
CI / UI / Build (pull_request) Successful in 22s
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (push) Successful in 32s
iOS CI / Build (pull_request) Successful in 1m52s
iOS CI / Test (pull_request) Successful in 3m50s
- PocketBase: new user_subscriptions collection (follower_id, followee_id)
- pocketbase.ts: subscribe/unsubscribe/getFollowingIds/getPublicProfile/
  getUserPublicLibrary/getUserCurrentlyReading/getSubscriptionFeed helpers
- GET /api/users/[username] — public profile with subscription state
- POST/DELETE /api/users/[username]/subscribe — follow/unfollow
- /users/[username] — public profile page: avatar, stats, follow button,
  currently reading grid, full library grid
- CommentsSection: usernames are now links to /users/[username]
- Home page: 'From People You Follow' section powered by subscription feed
2026-03-10 22:27:18 +05:00
Admin
8d4bba7964 feat(web): add /books/[slug]/chapters listing page
Some checks failed
CI / Scraper / Lint (pull_request) Failing after 8s
CI / Scraper / Test (pull_request) Successful in 9s
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
CI / UI / Build (push) Successful in 36s
CI / UI / Docker Push (push) Successful in 29s
iOS CI / Build (push) Successful in 2m0s
iOS CI / Build (pull_request) Successful in 3m29s
iOS CI / Test (push) Successful in 5m45s
iOS CI / Test (pull_request) Successful in 5m8s
Full chapter index with client-side search, 100-chapter page groups,
jump-to-current banner, and amber highlight on reading chapter.
2026-03-10 22:06:33 +05:00
Admin
2e5fe54615 fix(ios): add SearchView and AccountMenuSheet to Xcode project 2026-03-10 21:57:39 +05:00
Admin
81265510ef feat: book detail refactor — compact chapters row + reader UX improvements
Some checks failed
CI / Scraper / Lint (pull_request) Failing after 6s
CI / Scraper / Test (pull_request) Successful in 18s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 16s
CI / UI / Build (push) Successful in 24s
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (push) Successful in 35s
iOS CI / Build (push) Successful in 4m34s
iOS CI / Build (pull_request) Successful in 4m48s
iOS CI / Test (push) Has started running
iOS CI / Test (pull_request) Successful in 5m59s
iOS BookDetailView: replace paginated inline chapter list with a single
tappable 'Chapters' row (showing reading progress) that opens
BookChaptersSheet — a searchable full-screen sheet with jump-to-current.

ChapterReaderView: hide tab bar in reader, swap back/Aa/ToC button order
(Aa left, ToC right, X rightmost), remove mini-player spacer (tab bar
and player are hidden).

HomeView: remove large HeroContinueCard, promote all continue-reading
items into a single horizontal shelf (Apple Books style) with progress
bar below each cover. NavigationLink now goes directly to the chapter.

Web +page.svelte: replace inline paginated chapter list with a compact
'Chapters' row linking to /books/[slug]/chapters. Admin scrape controls
are now a collapsible row inside the same card.
2026-03-10 21:51:18 +05:00
Admin
4d3c093612 feat(ios): replace profile tab with search tab, add avatar button opening account sheet
Some checks failed
CI / Scraper / Lint (pull_request) Failing after 6s
CI / Scraper / Test (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 / Build (push) Successful in 1m44s
iOS CI / Build (pull_request) Successful in 1m36s
iOS CI / Test (push) Successful in 5m51s
iOS CI / Test (pull_request) Successful in 4m19s
2026-03-10 21:30:28 +05:00
Admin
937ba052fc fix(ios): rewrite avatar crop — correct pixel mapping and drag clamping
Some checks failed
CI / Scraper / Lint (pull_request) Failing after 13s
CI / Scraper / Test (pull_request) Successful in 18s
CI / UI / Build (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) Successful in 2m1s
iOS CI / Test (pull_request) Successful in 5m49s
2026-03-10 21:14:46 +05:00
Admin
479d201da9 fix(ios): fix comment delete — use fetchVoid for 204 No Content response
Some checks failed
CI / Scraper / Lint (pull_request) Failing after 7s
CI / Scraper / Test (pull_request) Successful in 19s
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) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Successful in 1m43s
iOS CI / Test (pull_request) Successful in 4m53s
2026-03-10 21:10:07 +05:00
Admin
1242cc7eb3 fix(ios): optimistic comment deletion with revert on failure
Some checks failed
CI / Scraper / Lint (pull_request) Failing after 10s
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) Successful in 1m56s
iOS CI / Test (pull_request) Successful in 5m27s
2026-03-10 21:02:50 +05:00
Admin
0b6dbeb042 ci: inject VERSION/COMMIT build-args into all docker build steps
Some checks failed
CI / Scraper / Lint (push) Failing after 9s
CI / Scraper / Test (push) Successful in 9s
CI / Scraper / Test (pull_request) Successful in 8s
CI / Scraper / Lint (pull_request) Failing after 11s
CI / UI / Build (push) Successful in 22s
Release / Scraper / Test (push) Failing after 11s
Release / Scraper / Docker (push) Has been skipped
CI / Scraper / Docker Push (push) Has been skipped
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
Release / UI / Build (push) Successful in 21s
CI / UI / Docker Push (push) Successful in 35s
Release / UI / Docker (push) Successful in 35s
iOS CI / Build (pull_request) Successful in 1m32s
iOS CI / Test (pull_request) Successful in 5m37s
All four workflows (ci-scraper, ci-ui, release-scraper, release-ui) now pass
build-args to docker/build-push-action. Release workflows use the semver tag
from docker/metadata-action outputs.version; CI workflows use the git SHA.
2026-03-10 20:23:07 +05:00
Admin
c06877069f fix: add missing DELETE handler and fix comment delete/vote URLs (web + iOS)
Some checks failed
CI / Scraper / Lint (pull_request) Failing after 12s
CI / Scraper / Test (pull_request) Successful in 18s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (push) Successful in 24s
CI / UI / Build (pull_request) Successful in 17s
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (push) Successful in 29s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Successful in 1m26s
iOS CI / Test (pull_request) Successful in 4m56s
The /api/comments/[id] delete route was never created; the deleteComment helper
in pocketbase.ts existed but was unreachable. Added DELETE /api/comment/[id]
route handler alongside the existing vote route. Updated CommentsSection.svelte
and iOS APIClient to use /api/comment/{id} for both delete and (already fixed)
vote, keeping all comment-mutation endpoints under the singular /api/comment/
prefix to avoid SvelteKit route conflicts with /api/comments/[slug].
2026-03-10 20:20:24 +05:00
Admin
261c738fc0 feat: inject build version/commit into scraper and UI at docker build time
Some checks failed
CI / Scraper / Lint (push) Failing after 6s
CI / Scraper / Lint (pull_request) Failing after 6s
CI / Scraper / Test (push) Successful in 16s
CI / Scraper / Test (pull_request) Successful in 9s
CI / Scraper / Docker Push (push) Has been skipped
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (push) Successful in 28s
CI / UI / Build (pull_request) Successful in 22s
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (push) Successful in 36s
iOS CI / Build (pull_request) Successful in 1m41s
iOS CI / Test (pull_request) Successful in 4m7s
- Go scraper: Version/Commit vars in main.go, injected via -ldflags; Server struct + New() updated; GET /health and new GET /api/version expose them
- UI Dockerfile: ARG BUILD_VERSION/BUILD_COMMIT → ENV PUBLIC_BUILD_VERSION/PUBLIC_BUILD_COMMIT for SvelteKit
- Footer: shows version+short commit when not 'dev' (text-zinc-800, subtle)
- docker-compose: args blocks for scraper and ui build sections pass $GIT_TAG/$GIT_COMMIT
2026-03-10 20:18:13 +05:00
Admin
5528abe4b0 fix: resolve SvelteKit route conflict by moving vote endpoint to /api/comment/[id]/vote
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 9s
CI / UI / Build (push) Successful in 23s
CI / Scraper / Test (pull_request) Successful in 24s
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
CI / UI / Docker Push (push) Successful in 30s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 19m9s
iOS CI / Test (pull_request) Has been cancelled
/api/comments/[id] and /api/comments/[slug] were ambiguous dynamic segments at
the same path level, causing a build error. Moved the vote handler to the
singular /api/comment/ prefix and updated all callers (web + iOS).
2026-03-10 20:12:46 +05:00
Admin
09cdda2a07 feat: add avatars to comments (web + iOS) with replies, delete, sort, and crop fix
Some checks failed
CI / Scraper / Test (push) Successful in 10s
CI / UI / Build (push) Failing after 9s
CI / Scraper / Lint (pull_request) Successful in 7s
CI / UI / Build (pull_request) Failing after 7s
CI / UI / Docker Push (push) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Lint (push) Successful in 28s
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 39s
iOS CI / Build (push) Successful in 2m16s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Successful in 5m35s
iOS CI / Test (pull_request) Successful in 5m50s
- Batch-resolve avatar presign URLs server-side in GET /api/comments/[slug];
  returns avatarUrls map alongside comments and myVotes
- CommentsSection.svelte: show avatar image or initials fallback (24px top-level,
  20px replies) next to each comment/reply username
- iOS CommentsResponse gains avatarUrls field; CommentsViewModel stores and
  populates it on load; CommentRow renders AsyncImage with initials fallback
- Also includes: comment replies (1-level nesting), delete, sort (Top/New),
  parent_id schema migration, and AvatarCropModal cropperjs fix
2026-03-10 20:05:31 +05:00
Admin
718bfa6691 fix(ui): bundle marked into server output via ssr.noExternal
Some checks failed
CI / UI / Build (push) Successful in 17s
CI / Scraper / Lint (pull_request) Successful in 16s
CI / Scraper / Test (pull_request) Successful in 18s
CI / UI / Build (pull_request) Successful in 16s
Release / Scraper / Test (push) Successful in 20s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
Release / UI / Build (push) Successful in 23s
CI / UI / Docker Push (push) Successful in 36s
Release / UI / Docker (push) Successful in 34s
Release / Scraper / Docker (push) Successful in 49s
iOS CI / Build (pull_request) Failing after 6m15s
iOS CI / Test (pull_request) Has been skipped
Production Docker image has no node_modules at runtime; marked was being
externalized by adapter-node (it is in dependencies), causing ERR_MODULE_NOT_FOUND
when +page.server.ts and +server.ts imported it. Adding it to ssr.noExternal
forces Vite to inline it into the server bundle.
2026-03-10 19:01:43 +05:00
Admin
e11e866e27 fix(ui): bundle marked by switching from async to sync API
Some checks failed
CI / Scraper / Test (pull_request) Successful in 12s
CI / Scraper / Lint (pull_request) Successful in 17s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (push) Successful in 27s
CI / UI / Build (pull_request) Successful in 18s
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (push) Successful in 30s
iOS CI / Build (pull_request) Failing after 1m58s
iOS CI / Test (pull_request) Has been skipped
marked({ async: true }) triggers a dynamic internal require inside marked
that vite/rollup treats as external, causing ERR_MODULE_NOT_FOUND at runtime
in the adapter-node Docker image which ships no node_modules.
Switching to the synchronous marked() call makes rollup inline the full
library into the server chunk.
2026-03-10 18:27:00 +05:00
Admin
23345e22e6 fix(ui): fix chapter page SSR crash by lazy-loading marked
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 13s
CI / UI / Build (push) Successful in 22s
CI / Scraper / Test (pull_request) Successful in 16s
Release / Scraper / Test (push) Successful in 15s
CI / UI / Build (pull_request) Successful in 24s
CI / Scraper / Docker Push (pull_request) Has been skipped
Release / UI / Build (push) Successful in 22s
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (push) Successful in 30s
Release / UI / Docker (push) Successful in 42s
Release / Scraper / Docker (push) Successful in 1m19s
iOS CI / Build (pull_request) Failing after 1m54s
iOS CI / Test (pull_request) Has been skipped
Static import of 'marked' was included in the SSR bundle causing
ERR_MODULE_NOT_FOUND on first server render. The import is only
ever used inside onMount (client-only fallback path), so replace
with a dynamic import() at the call site.
2026-03-10 18:20:35 +05:00
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
98 changed files with 11252 additions and 1566 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,33 @@ 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 }}
build-args: |
VERSION=${{ gitea.sha }}
COMMIT=${{ 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,38 @@ 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 }}
build-args: |
BUILD_VERSION=${{ gitea.sha }}
BUILD_COMMIT=${{ 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,68 @@
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 }}
build-args: |
VERSION=${{ steps.meta.outputs.version }}
COMMIT=${{ gitea.sha }}

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,32 @@ 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 }}
build-args: |
BUILD_VERSION=${{ steps.meta.outputs.version }}
BUILD_COMMIT=${{ gitea.sha }}

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:
@@ -81,6 +82,9 @@ services:
build:
context: ./scraper
dockerfile: Dockerfile
args:
VERSION: "${GIT_TAG:-dev}"
COMMIT: "${GIT_COMMIT:-unknown}"
#container_name: libnovel-scraper
restart: unless-stopped
depends_on:
@@ -106,6 +110,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:-}"
@@ -129,6 +134,9 @@ services:
build:
context: ./ui
dockerfile: Dockerfile
args:
BUILD_VERSION: "${GIT_TAG:-dev}"
BUILD_COMMIT: "${GIT_COMMIT:-unknown}"
# container_name: libnovel-ui
restart: unless-stopped
depends_on:
@@ -147,7 +155,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 */; };
@@ -23,14 +24,18 @@
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */; };
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.swift */; };
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */; };
A1C3F2B84D9E72A1BC054F17 /* CommentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */; };
A2F1C3B84E9D71A0BC164F28 /* AccountMenuSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */; };
A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB13E89E50529E3081533A66 /* AudioPlayerService.swift */; };
BD2CA5EE70D102CA3B153485 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 6313AB4B3A5464F647791174 /* MarkdownUI */; };
AA11BB22CC33DD44EE55FF66 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF67 /* UserProfileView.swift */; };
BB22CC33DD44EE55FF66AA11 /* UserProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB22CC33DD44EE55FF66AA12 /* UserProfileViewModel.swift */; };
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */; };
C3D7A2E15F8B04C9AB163D50 /* AvatarCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */; };
C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB2E843D93461074A89A171 /* HomeViewModel.swift */; };
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 09584EAB68A07B47F876A062 /* Kingfisher */; };
D5E2A1C96F3B08D0F74C6B50 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */; };
E1F564399D1325F6A1B2B84F /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C21107BECA55C07416E0CB8B /* LibraryView.swift */; };
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D83BB88C4306BE7A4F947CB /* Color+App.swift */; };
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 +56,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,16 +73,22 @@
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>"; };
AA11BB22CC33DD44EE55FF67 /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = "<group>"; };
B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsView.swift; sourceTree = "<group>"; };
B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMenuSheet.swift; sourceTree = "<group>"; };
B4C918833E173D6B44D06955 /* LibNovelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelTests.swift; sourceTree = "<group>"; };
B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
BB22CC33DD44EE55FF66AA12 /* UserProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileViewModel.swift; sourceTree = "<group>"; };
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
C21107BECA55C07416E0CB8B /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCropView.swift; sourceTree = "<group>"; };
D6268D60803940CBD38FB921 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
DB13E89E50529E3081533A66 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; };
DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViews.swift; sourceTree = "<group>"; };
F219788AE5ACBD6F240674F5 /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = "<group>"; };
FC338B05EA6DB22900712000 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = "<group>"; };
FEC6F837FF2E902E334ED72E /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+App.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -86,7 +97,6 @@
buildActionMask = 2147483647;
files = (
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */,
BD2CA5EE70D102CA3B153485 /* MarkdownUI in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -113,6 +123,7 @@
FA994FD601E79EC811D822A4 /* Library */,
89F2CB14192E7D7565A588E0 /* Player */,
3DB66C5703A4CCAFFA1B7AFE /* Profile */,
E6A2B4C07F1D38E0A95B3C72 /* Search */,
);
path = Views;
sourceTree = "<group>";
@@ -129,6 +140,9 @@
isa = PBXGroup;
children = (
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */,
D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */,
B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */,
AA11BB22CC33DD44EE55FF67 /* UserProfileView.swift */,
);
path = Profile;
sourceTree = "<group>";
@@ -242,6 +256,7 @@
3AB2E843D93461074A89A171 /* HomeViewModel.swift */,
FC338B05EA6DB22900712000 /* LibraryViewModel.swift */,
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */,
BB22CC33DD44EE55FF66AA12 /* UserProfileViewModel.swift */,
);
path = ViewModels;
sourceTree = "<group>";
@@ -255,6 +270,14 @@
path = Services;
sourceTree = "<group>";
};
E6A2B4C07F1D38E0A95B3C72 /* Search */ = {
isa = PBXGroup;
children = (
C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */,
);
path = Search;
sourceTree = "<group>";
};
FA994FD601E79EC811D822A4 /* Library */ = {
isa = PBXGroup;
children = (
@@ -267,6 +290,7 @@
isa = PBXGroup;
children = (
39DE056C37FBC5EED8771821 /* BookDetailView.swift */,
B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */,
);
path = BookDetail;
sourceTree = "<group>";
@@ -276,7 +300,7 @@
children = (
9D83BB88C4306BE7A4F947CB /* Color+App.swift */,
7CAFB96D2500F34F0B0C860C /* NavDestination.swift */,
B2C3D4E5F67890123456789A /* String+App.swift */,
FEC6F837FF2E902E334ED72E /* String+App.swift */,
);
path = Extensions;
sourceTree = "<group>";
@@ -327,7 +351,6 @@
name = LibNovel;
packageProductDependencies = (
09584EAB68A07B47F876A062 /* Kingfisher */,
6313AB4B3A5464F647791174 /* MarkdownUI */,
);
productName = LibNovel;
productReference = 1B8BF3DB582A658386E402C7 /* LibNovel.app */;
@@ -340,7 +363,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1600;
LastUpgradeCheck = 2630;
};
buildConfigurationList = D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */;
developmentRegion = en;
@@ -353,7 +376,6 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */,
C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 6318D3C6F0DC6C8E2C377103 /* Products */;
@@ -395,13 +417,13 @@
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */,
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */,
749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */,
A1C3F2B84D9E72A1BC054F17 /* CommentsView.swift in Sources */,
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */,
08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */,
2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */,
FEFB5FDC2424D22914458001 /* ChapterReaderView.swift in Sources */,
2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */,
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */,
A1B2C3D4E5F6789012345678 /* String+App.swift in Sources */,
F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */,
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */,
EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */,
@@ -413,8 +435,14 @@
F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */,
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */,
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */,
C3D7A2E15F8B04C9AB163D50 /* AvatarCropView.swift in Sources */,
A2F1C3B84E9D71A0BC164F28 /* AccountMenuSheet.swift in Sources */,
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */,
AA11BB22CC33DD44EE55FF66 /* UserProfileView.swift in Sources */,
BB22CC33DD44EE55FF66AA11 /* UserProfileViewModel.swift in Sources */,
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */,
D5E2A1C96F3B08D0F74C6B50 /* SearchView.swift in Sources */,
41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -439,7 +467,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 +484,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 +495,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 +549,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 +577,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 +588,25 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "iPhone Developer";
DEVELOPMENT_TEAM = GHZXC6FVMU;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
"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 = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LibNovel Distribution";
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
@@ -602,11 +646,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 +667,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 +716,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 +724,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

@@ -8,51 +8,52 @@ struct RootTabView: View {
@State private var selectedTab: Tab = .home
@State private var showFullPlayer: Bool = false
@State private var showCompactControls: Bool = false
/// Live drag offset while the user is dragging the full player down.
@State private var fullPlayerDragOffset: CGFloat = 0
enum Tab: Hashable {
case home, library, browse, profile
case home, library, browse, search
}
/// Height of the mini player bar (progress line 2pt + vertical padding 20pt + content ~44pt)
private let miniPlayerBarHeight: CGFloat = AppLayout.miniPlayerBarHeight
var body: some View {
ZStack(alignment: .bottom) {
TabView(selection: $selectedTab) {
HomeView()
.tabItem { Label("Home", systemImage: "house") }
.tabItem { Label("Home", systemImage: "house.fill") }
.tag(Tab.home)
LibraryView()
.tabItem { Label("Library", systemImage: "books.vertical") }
.tabItem { Label("Library", systemImage: "book.pages.fill") }
.tag(Tab.library)
BrowseView()
.tabItem { Label("Discover", systemImage: "sparkles") }
.tag(Tab.browse)
ProfileView()
.tabItem { Label("Profile", systemImage: "person.crop.circle") }
.tag(Tab.profile)
}
// Reserve space for the mini-player above the tab bar so scroll content
// never slides beneath it.
.safeAreaInset(edge: .bottom) {
if audioPlayer.isActive {
Color.clear.frame(height: miniPlayerBarHeight)
}
SearchView()
.tabItem { Label("Search", systemImage: "magnifyingglass") }
.tag(Tab.search)
}
// Mini-player pinned above the tab bar (hidden while full player is open)
// Floating circular player button (hidden while full player is open)
if audioPlayer.isActive && !showFullPlayer {
MiniPlayerView(showFullPlayer: $showFullPlayer)
.padding(.horizontal, 12)
.padding(.bottom, max(tabBarHeight - 2, 0))
.transition(.move(edge: .bottom).combined(with: .opacity))
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive)
ZStack {
// Compact controls overlay (bottom sheet)
if showCompactControls {
CompactPlayerControls(isPresented: $showCompactControls)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
// Floating button (always on top)
FloatingPlayerButton(
showFullPlayer: $showFullPlayer,
showControls: $showCompactControls
)
}
.transition(.scale.combined(with: .opacity))
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive)
}
// Full player slides up from the bottom as a custom overlay (not a sheet)
@@ -93,14 +94,6 @@ struct RootTabView: View {
}
}
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer)
}
// Approximate safe-area-aware tab bar height
private var tabBarHeight: CGFloat {
let window = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first?.windows.first(where: \.isKeyWindow)
let bottomInset = window?.safeAreaInsets.bottom ?? 0
return 49 + bottomInset // 49pt is the standard iOS tab bar height
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: showCompactControls)
}
}

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

@@ -5,4 +5,100 @@ import SwiftUI
enum NavDestination: Hashable {
case book(String) // slug
case chapter(String, Int) // slug + chapter number
case userProfile(String) // username
}
// MARK: - View extensions for shared navigation + error alert patterns
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)
case .userProfile(let username):
UserProfileView(username: username)
}
}
// 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)
case .userProfile(let username): UserProfileView(username: username)
}
}
}
}
}
// 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 ""
}
}
@@ -30,11 +39,3 @@ extension String {
return self
}
}
// MARK: - App-wide layout constants
enum AppLayout {
/// Height of the persistent mini-player bar:
/// 2pt progress line + 20pt vertical padding + ~44pt content row.
static let miniPlayerBarHeight: CGFloat = 56
}

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,140 @@ 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
let parentId: String // empty = top-level; non-empty = reply
var replies: [BookComment]? // populated client-side from the API response
enum CodingKeys: String, CodingKey {
case id, slug, username, body, upvotes, downvotes, created, replies
case userId = "user_id"
case parentId = "parent_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) ?? ""
parentId = try c.decodeIfPresent(String.self, forKey: .parentId) ?? ""
replies = try c.decodeIfPresent([BookComment].self, forKey: .replies)
}
}
struct CommentsResponse: Decodable {
let comments: [BookComment]
let myVotes: [String: String]
let avatarUrls: [String: String]
enum CodingKeys: String, CodingKey {
case comments
case myVotes = "myVotes"
case avatarUrls = "avatarUrls"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
comments = try c.decode([BookComment].self, forKey: .comments)
myVotes = try c.decodeIfPresent([String: String].self, forKey: .myVotes) ?? [:]
avatarUrls = try c.decodeIfPresent([String: String].self, forKey: .avatarUrls) ?? [:]
}
}
// MARK: - User Profile (public)
struct PublicUserProfile: Decodable, Identifiable {
let id: String
let username: String
let avatarUrl: String?
let created: String
let followerCount: Int
let followingCount: Int
let isSubscribed: Bool
let isSelf: Bool
enum CodingKeys: String, CodingKey {
case id, username, created
case avatarUrl = "avatarUrl"
case followerCount = "followerCount"
case followingCount = "followingCount"
case isSubscribed = "isSubscribed"
case isSelf = "isSelf"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
username = try c.decode(String.self, forKey: .username)
avatarUrl = try c.decodeIfPresent(String.self, forKey: .avatarUrl)
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
followerCount = try c.decodeIfPresent(Int.self, forKey: .followerCount) ?? 0
followingCount = try c.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0
isSubscribed = try c.decodeIfPresent(Bool.self, forKey: .isSubscribed) ?? false
isSelf = try c.decodeIfPresent(Bool.self, forKey: .isSelf) ?? false
}
}
// MARK: - Subscription Feed
struct SubscriptionFeedItem: Identifiable, Decodable {
var id: String { book.id + readerUsername }
let book: Book
let readerUsername: String
enum CodingKeys: String, CodingKey {
case book
case readerUsername = "readerUsername"
}
}
// MARK: - Public User Library
struct PublicLibraryItem: Decodable, Identifiable {
var id: String { book.id }
let book: Book
let lastChapter: Int?
let saved: Bool
enum CodingKeys: String, CodingKey {
case book
case lastChapter = "last_chapter"
case saved
}
}
struct PublicUserLibraryResponse: Decodable {
let currentlyReading: [PublicLibraryItem]
let library: [PublicLibraryItem]
enum CodingKeys: String, CodingKey {
case currentlyReading = "currently_reading"
case library
}
}
// MARK: - Audio
enum NextPrefetchStatus {
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,49 +77,30 @@ 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) {
/// Like `fetch` but discards the response body use for endpoints that return 204 No Content.
func fetchVoid(_ path: String, method: String = "GET", body: Encodable? = nil) async throws {
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)
guard let http = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard (200..<300).contains(http.statusCode) else {
let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8 data, \(data.count) bytes>"
throw APIError.httpError(http.statusCode, rawBody)
}
}
// MARK: - Auth
@@ -158,7 +126,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 +164,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)")
@@ -228,6 +189,10 @@ actor APIClient {
let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "POST", body: Body(chapter: chapter))
}
func deleteProgress(slug: String) async throws {
let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "DELETE")
}
func audioTime(slug: String, chapter: Int) async throws -> Double? {
struct Response: Decodable { let audioTime: Double?; enum CodingKeys: String, CodingKey { case audioTime = "audio_time" } }
let r: Response = try await fetch("/api/progress/audio-time?slug=\(slug)&chapter=\(chapter)")
@@ -316,6 +281,115 @@ 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: - User Profiles & Subscriptions
func fetchUserProfile(username: String) async throws -> PublicUserProfile {
try await fetch("/api/users/\(username)")
}
@discardableResult
func subscribeUser(username: String) async throws -> Bool {
struct Response: Decodable { let subscribed: Bool }
let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "POST")
return r.subscribed
}
@discardableResult
func unsubscribeUser(username: String) async throws -> Bool {
struct Response: Decodable { let subscribed: Bool }
let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "DELETE")
return r.subscribed
}
func fetchUserLibrary(username: String) async throws -> PublicUserLibraryResponse {
try await fetch("/api/users/\(username)/library")
}
// MARK: - Comments
func fetchComments(slug: String, sort: String = "top") async throws -> CommentsResponse {
try await fetch("/api/comments/\(slug)?sort=\(sort)")
}
struct PostCommentBody: Encodable {
let body: String
let parent_id: String?
}
func postComment(slug: String, body: String, parentId: String? = nil) async throws -> BookComment {
try await fetch("/api/comments/\(slug)", method: "POST", body: PostCommentBody(body: body, parent_id: parentId))
}
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/comment/\(commentId)/vote", method: "POST", body: VoteBody(vote: vote))
}
/// Delete a comment (and its replies) by ID. Only the owner can delete.
func deleteComment(commentId: String) async throws {
try await fetchVoid("/api/comment/\(commentId)", method: "DELETE")
}
}
// MARK: - Response types
@@ -328,11 +402,21 @@ struct HomeDataResponse: Decodable {
let continueReading: [ContinueItem]
let recentlyUpdated: [Book]
let stats: HomeStats
let subscriptionFeed: [SubscriptionFeedItem]
enum CodingKeys: String, CodingKey {
case continueReading = "continue_reading"
case recentlyUpdated = "recently_updated"
case stats
case subscriptionFeed = "subscription_feed"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
continueReading = try c.decodeIfPresent([ContinueItem].self, forKey: .continueReading) ?? []
recentlyUpdated = try c.decodeIfPresent([Book].self, forKey: .recentlyUpdated) ?? []
stats = try c.decode(HomeStats.self, forKey: .stats)
subscriptionFeed = try c.decodeIfPresent([SubscriptionFeedItem].self, forKey: .subscriptionFeed) ?? []
}
}
@@ -386,11 +470,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,32 @@
<!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>
<string>fetch</string>
<string>processing</string>
</array>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
@@ -31,13 +41,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,14 +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 sleepTimerOption: SleepTimerOption = .off
@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 = ""
@@ -58,13 +91,18 @@ final class AudioPlayerService: ObservableObject {
private var generationTask: Task<Void, Never>?
private var prefetchTask: Task<Void, Never>?
private var sleepTimer: Timer?
private var sleepRemainingChapters: Int?
// Cached cover image downloaded once per chapter load, reused on every
// updateNowPlaying() call so we don't re-download on every play/pause/seek.
private var cachedCoverArtwork: MPMediaItemArtwork?
private var cachedCoverURL: String = ""
// Sleep timer tracking
private var sleepTimerTask: Task<Void, Never>?
private var sleepTimerStartChapter: Int = 0
/// Absolute deadline for minute-based timers (nil when not active or chapter-based).
private var sleepTimerDeadline: Date? = nil
/// 1-second tick task that keeps sleepTimerRemainingText up-to-date.
private var sleepTimerCountdownTask: Task<Void, Never>? = nil
// MARK: - Init
@@ -78,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()
@@ -92,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
@@ -144,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()
@@ -153,49 +260,15 @@ final class AudioPlayerService: ObservableObject {
duration = 0
audioURL = ""
status = .idle
clearSleepTimerIfNeeded()
}
func playChapter(number: Int) {
guard !slug.isEmpty else { return }
let title = chapters.first(where: { $0.number == number })?.title ?? ""
let next = chapters.first(where: { $0.number > number })?.number
load(
slug: slug,
chapter: number,
chapterTitle: title,
bookTitle: bookTitle,
coverURL: coverURL,
voice: voice,
speed: speed,
chapters: chapters,
nextChapter: next
)
}
func setSleepTimer(_ option: SleepTimerOption) {
sleepTimer?.invalidate()
// Cancel sleep timer + countdown
sleepTimerTask?.cancel()
sleepTimerTask = nil
sleepTimerCountdownTask?.cancel()
sleepTimerCountdownTask = nil
sleepTimerDeadline = nil
sleepTimer = nil
sleepRemainingChapters = nil
sleepTimerOption = option
switch option {
case .off:
break
case .endOfChapter:
sleepRemainingChapters = 1
case .chapters(let count):
sleepRemainingChapters = max(1, count)
case .minutes(let minutes):
let seconds = max(60, minutes * 60)
sleepTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(seconds), repeats: false) { [weak self] _ in
Task { @MainActor in
self?.stop()
self?.sleepTimerOption = .off
self?.sleepRemainingChapters = nil
}
}
}
sleepTimerRemainingText = ""
}
// MARK: - Audio generation
@@ -357,51 +430,38 @@ final class AudioPlayerService: ObservableObject {
playerItem = nil
}
private func clearSleepTimerIfNeeded() {
sleepTimer?.invalidate()
sleepTimer = nil
sleepRemainingChapters = nil
sleepTimerOption = .off
}
private func handlePlaybackFinished() {
isPlaying = false
var shouldAutoNext = autoNext
var didTriggerSleepStop = false
if let remaining = sleepRemainingChapters {
let updated = remaining - 1
if updated <= 0 {
sleepRemainingChapters = nil
sleepTimerOption = .off
didTriggerSleepStop = true
shouldAutoNext = false
} else {
sleepRemainingChapters = updated
}
}
if didTriggerSleepStop {
stop()
return
}
guard let next = nextChapter else { return }
// Check chapter-based sleep timer
if case .chapters(let count) = sleepTimer {
let chaptersPlayed = chapter - sleepTimerStartChapter + 1
if chaptersPlayed >= count {
stop()
return
}
// Update the remaining chapters label.
let remaining = count - chaptersPlayed
updateChapterTimerLabel(chaptersRemaining: remaining)
}
// Always notify the view that the chapter finished (it may update UI).
NotificationCenter.default.post(
name: .audioDidFinishChapter,
object: nil,
userInfo: ["next": next, "autoNext": shouldAutoNext]
userInfo: ["next": next, "autoNext": autoNext]
)
// If autoNext is on, load the next chapter internally right away.
// We already have the metadata in `chapters`, so we can reconstruct
// everything without waiting for the view to navigate.
guard shouldAutoNext else { return }
guard autoNext else { return }
let nextTitle = chapters.first(where: { $0.number == next })?.title ?? ""
let nextNextChapter = chapters.first(where: { $0.number > next })?.number
let nextPrevChapter: Int? = chapter // Current chapter becomes previous for the next one
// If we already prefetched a URL for the next chapter, skip straight to
// playback and kick off generation in the background for the one after.
@@ -412,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)
@@ -434,7 +500,8 @@ final class AudioPlayerService: ObservableObject {
voice: voice,
speed: speed,
chapters: chapters,
nextChapter: nextNextChapter
nextChapter: nextNextChapter,
prevChapter: nextPrevChapter
)
}
}
@@ -443,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
@@ -480,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]
@@ -535,12 +605,12 @@ enum AudioPlayerStatus: Equatable {
}
enum SleepTimerOption: Equatable {
case off
case endOfChapter
case chapters(Int)
case minutes(Int)
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

@@ -5,6 +5,7 @@ final class HomeViewModel: ObservableObject {
@Published var continueReading: [ContinueReadingItem] = []
@Published var recentlyUpdated: [Book] = []
@Published var stats: HomeStats?
@Published var subscriptionFeed: [SubscriptionFeedItem] = []
@Published var isLoading = false
@Published var error: String?
@@ -18,6 +19,7 @@ final class HomeViewModel: ObservableObject {
}
recentlyUpdated = data.recentlyUpdated
stats = data.stats
subscriptionFeed = data.subscriptionFeed
} catch {
if !(error is CancellationError) {
self.error = error.localizedDescription

View File

@@ -0,0 +1,87 @@
import Foundation
@MainActor
final class UserProfileViewModel: ObservableObject {
let username: String
@Published var profile: PublicUserProfile?
@Published var currentlyReading: [PublicLibraryItem] = []
@Published var library: [PublicLibraryItem] = []
@Published var isLoading = false
@Published var isTogglingSubscribe = false
@Published var error: String?
init(username: String) {
self.username = username
}
func load() async {
guard !isLoading else { return }
isLoading = true
error = nil
do {
async let profileFetch = APIClient.shared.fetchUserProfile(username: username)
async let libraryFetch = APIClient.shared.fetchUserLibrary(username: username)
let (p, lib) = try await (profileFetch, libraryFetch)
profile = p
currentlyReading = lib.currentlyReading
library = lib.library
} catch let apiError as APIError {
switch apiError {
case .httpError(404, _): error = "User not found."
default: error = apiError.localizedDescription
}
} catch {
if !(error is CancellationError) {
self.error = error.localizedDescription
}
}
isLoading = false
}
func toggleSubscribe() async {
guard let p = profile, !p.isSelf, !isTogglingSubscribe else { return }
isTogglingSubscribe = true
defer { isTogglingSubscribe = false }
do {
if p.isSubscribed {
try await APIClient.shared.unsubscribeUser(username: username)
profile = PublicUserProfile(
id: p.id, username: p.username, avatarUrl: p.avatarUrl,
created: p.created,
followerCount: max(0, p.followerCount - 1),
followingCount: p.followingCount,
isSubscribed: false, isSelf: p.isSelf
)
} else {
try await APIClient.shared.subscribeUser(username: username)
profile = PublicUserProfile(
id: p.id, username: p.username, avatarUrl: p.avatarUrl,
created: p.created,
followerCount: p.followerCount + 1,
followingCount: p.followingCount,
isSubscribed: true, isSelf: p.isSelf
)
}
} catch {
self.error = error.localizedDescription
}
}
}
// MARK: - Convenience memberwise init for PublicUserProfile (used in optimistic updates)
private extension PublicUserProfile {
init(id: String, username: String, avatarUrl: String?, created: String,
followerCount: Int, followingCount: Int, isSubscribed: Bool, isSelf: Bool) {
// Encode then decode to go through the standard Decodable path without duplicating code
var dict: [String: Any] = [
"id": id, "username": username, "created": created,
"followerCount": followerCount, "followingCount": followingCount,
"isSubscribed": isSubscribed, "isSelf": isSelf
]
if let url = avatarUrl { dict["avatarUrl"] = url }
let data = try! JSONSerialization.data(withJSONObject: dict)
self = try! JSONDecoder().decode(PublicUserProfile.self, from: data)
}
}

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
@@ -6,8 +7,7 @@ struct BookDetailView: View {
@EnvironmentObject var authStore: AuthStore
@EnvironmentObject var audioPlayer: AudioPlayerService
@State private var summaryExpanded = false
@State private var chapterPage = 0
private let pageSize = 50
@State private var showChapters = false
init(slug: String) {
self.slug = slug
@@ -15,24 +15,36 @@ 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) {
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)
chaptersRow(book: book)
Divider().padding(.horizontal)
CommentsView(slug: slug)
}
}
}
.ignoresSafeArea(edges: .top)
}
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.appNavigationDestination()
.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)
.sheet(isPresented: $showChapters) {
BookChaptersSheet(
slug: slug,
chapters: vm.chapters,
lastChapter: vm.lastChapter,
totalChapters: vm.book?.totalChapters ?? 0
)
}
}
// MARK: - Hero
@@ -40,139 +52,191 @@ 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)
VStack(spacing: 16) {
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) {
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))
}
if !book.genres.isEmpty {
HStack(spacing: 8) {
ForEach(book.genres.prefix(3), id: \.self) { genre in
TagChip(label: genre).colorScheme(.dark)
}
}
}
Spacer(minLength: 0)
if !book.status.isEmpty {
StatusBadge(status: book.status)
}
}
.padding(.horizontal)
.padding(.bottom, 16)
.padding(.bottom, 28)
}
// Summary
VStack(alignment: .leading, spacing: 8) {
Text(book.summary)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(summaryExpanded ? nil : 4)
if book.summary.count > 200 {
Button(summaryExpanded ? "Less" : "More") {
withAnimation { summaryExpanded.toggle() }
}
.font(.caption.bold())
.foregroundStyle(.amber)
}
}
.padding()
// CTA buttons
HStack(spacing: 10) {
if let last = vm.lastChapter, last > 0 {
NavigationLink(value: NavDestination.chapter(slug, last)) {
Label("Continue Ch.\(last)", systemImage: "play.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.amber)
NavigationLink(value: NavDestination.chapter(slug, 1)) {
Label("From Ch.1", systemImage: "arrow.counterclockwise")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.secondary)
} else {
NavigationLink(value: NavDestination.chapter(slug, 1)) {
Label("Start Reading", systemImage: "book.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.amber)
}
}
.padding(.horizontal)
.padding(.bottom, 8)
.frame(minHeight: 320)
}
// MARK: - Chapter list
// MARK: - Meta section (stats + summary + CTAs)
@ViewBuilder
private func chapterSection(book: Book) -> some View {
let chapters = vm.chapters
let total = chapters.count
let start = chapterPage * pageSize
let end = min(start + pageSize, total)
let pageChapters = Array(chapters[start..<end])
private func metaSection(book: Book) -> some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Chapters")
.font(.title3.bold())
Spacer()
if total > 0 {
Text("\(start + 1)\(end) of \(total)")
.font(.caption)
.foregroundStyle(.secondary)
// Quick stats row
HStack(spacing: 0) {
MetaStat(value: "\(book.totalChapters)", label: "Chapters", icon: "doc.text")
Divider().frame(height: 36)
MetaStat(
value: book.status.capitalized.isEmpty ? "" : book.status.capitalized,
label: "Status", icon: "flag"
)
if book.ranking > 0 {
Divider().frame(height: 36)
MetaStat(value: "#\(book.ranking)", label: "Rank", icon: "chart.bar.fill")
}
}
.padding(.vertical, 16)
.frame(maxWidth: .infinity)
Divider().padding(.horizontal)
// Summary
VStack(alignment: .leading, spacing: 8) {
Text("About")
.font(.headline)
Text(book.summary)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(summaryExpanded ? nil : 4)
.animation(.easeInOut(duration: 0.2), value: summaryExpanded)
if book.summary.count > 200 {
Button(summaryExpanded ? "Less" : "More") {
withAnimation { summaryExpanded.toggle() }
}
.font(.caption.bold())
.foregroundStyle(.amber)
}
}
.padding(.horizontal)
.padding(.vertical, 10)
.padding(.vertical, 16)
if vm.chaptersLoading {
ProgressView().frame(maxWidth: .infinity).padding()
} else {
ForEach(pageChapters) { ch in
NavigationLink(value: NavDestination.chapter(slug, ch.number)) {
ChapterRow(chapter: ch, isCurrent: ch.number == vm.lastChapter)
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(.plain)
Divider().padding(.leading)
}
}
.buttonStyle(.borderedProminent)
.tint(.amber)
// Pagination
if total > pageSize {
HStack {
Button("Previous") { chapterPage -= 1 }
.disabled(chapterPage == 0)
Spacer()
Button("Next") { chapterPage += 1 }
.disabled(end >= total)
NavigationLink(value: NavDestination.chapter(slug, 1)) {
Label("From Ch.1", systemImage: "arrow.counterclockwise")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.secondary)
} else {
NavigationLink(value: NavDestination.chapter(slug, 1)) {
Label("Start Reading", systemImage: "book.fill")
.frame(maxWidth: .infinity)
.fontWeight(.semibold)
}
.buttonStyle(.borderedProminent)
.tint(.amber)
}
.buttonStyle(.bordered)
.padding()
}
.padding(.horizontal)
.padding(.vertical, 16)
}
}
// MARK: - Toolbar bookmark
// MARK: - Compact chapters row (tap sheet)
@ViewBuilder
private func chaptersRow(book: Book) -> some View {
Button {
showChapters = true
} label: {
HStack(spacing: 12) {
Image(systemName: "list.number")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.amber)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text("Chapters")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
if !vm.chapters.isEmpty {
let last = vm.lastChapter
let total = vm.chapters.count
Text(last != nil && last! > 0
? "Reading Ch.\(last!) of \(total)"
: "\(total) chapter\(total == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(.secondary)
} else if vm.isLoading {
Text("Loading…")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
// MARK: - Bookmark toolbar
@ToolbarContentBuilder
private var bookmarkButton: some ToolbarContent {
@@ -187,38 +251,241 @@ struct BookDetailView: View {
}
}
// MARK: - Chapters list sheet
struct BookChaptersSheet: View {
let slug: String
let chapters: [ChapterIndex]
let lastChapter: Int?
let totalChapters: Int
@Environment(\.dismiss) private var dismiss
@State private var searchText = ""
@State private var scrollToCurrentOnAppear = true
private var filtered: [ChapterIndex] {
guard !searchText.isEmpty else { return chapters }
let q = searchText.lowercased()
return chapters.filter {
"chapter \($0.number)".contains(q) ||
$0.title.lowercased().contains(q)
}
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Search bar
HStack(spacing: 8) {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
TextField("Search chapters…", text: $searchText)
.autocorrectionDisabled()
if !searchText.isEmpty {
Button { searchText = "" } label: {
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
}
}
}
.padding(10)
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
.padding(.horizontal)
.padding(.vertical, 10)
Divider()
// Jump-to-current banner (shown when user has progress and not searching)
if let last = lastChapter, last > 0, searchText.isEmpty {
Button {
scrollToCurrentOnAppear = true
} label: {
HStack(spacing: 8) {
Image(systemName: "arrow.down.circle.fill")
.foregroundStyle(.amber)
Text("Jump to Ch.\(last)")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.amber)
Spacer()
let pct = totalChapters > 0
? Int(Double(last) / Double(totalChapters) * 100)
: 0
Text("\(pct)% read")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
.buttonStyle(.plain)
Divider()
}
if chapters.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if filtered.isEmpty {
VStack(spacing: 10) {
Image(systemName: "magnifyingglass")
.font(.largeTitle)
.foregroundStyle(.secondary)
Text("No chapters match \"\(searchText)\"")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollViewReader { proxy in
List {
ForEach(filtered) { ch in
NavigationLink(value: NavDestination.chapter(slug, ch.number)) {
ChapterRow(
chapter: ch,
isCurrent: ch.number == lastChapter,
totalChapters: chapters.count
)
}
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16))
.id(ch.number)
}
}
.listStyle(.plain)
.appNavigationDestination()
.onAppear {
if scrollToCurrentOnAppear, let last = lastChapter, last > 0 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
withAnimation {
proxy.scrollTo(last, anchor: .center)
}
}
scrollToCurrentOnAppear = false
}
}
.onChange(of: scrollToCurrentOnAppear) { _, jump in
if jump, let last = lastChapter, last > 0 {
withAnimation {
proxy.scrollTo(last, anchor: .center)
}
scrollToCurrentOnAppear = false
}
}
}
}
}
.navigationTitle("Chapters")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
.fontWeight(.semibold)
}
}
}
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
}
// MARK: - Chapter row (reused by sheet)
private struct ChapterRow: View {
let chapter: ChapterIndex
let isCurrent: Bool
let totalChapters: Int
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: 12) {
// Number badge
ZStack {
Circle()
.fill(isCurrent ? Color.amber : Color(.systemGray6))
Text("\(chapter.number)")
.font(.caption2.bold().monospacedDigit())
.foregroundStyle(isCurrent ? .black : .secondary)
.minimumScaleFactor(0.6)
}
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)
if !chapter.dateLabel.isEmpty {
Text(chapter.dateLabel)
.font(.caption2)
.foregroundStyle(.tertiary)
.fixedSize()
}
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(.tertiary)
}
Spacer(minLength: 8)
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,643 @@
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 avatarUrls: [String: String] = [:] // userId presigned URL
@Published var isLoading = true
@Published var error: String?
@Published var newBody = ""
@Published var isPosting = false
@Published var postError: String?
@Published var sort: CommentSortOrder = .top
// Reply state
@Published var replyingToId: String? = nil
@Published var replyBody = ""
@Published var isPostingReply = false
@Published var replyError: String?
private var votingIds: Set<String> = []
private var deletingIds: 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, sort: sort.rawValue)
comments = response.comments
myVotes = response.myVotes
avatarUrls = response.avatarUrls
} 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 {
var created = try await APIClient.shared.postComment(slug: slug, body: text)
created.replies = []
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 postReply(parentId: String) async {
let text = replyBody.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !isPostingReply else { return }
if text.count > 2000 {
replyError = "Reply too long (max 2000 characters)."
return
}
isPostingReply = true
replyError = nil
do {
let created = try await APIClient.shared.postComment(slug: slug, body: text, parentId: parentId)
if let idx = comments.firstIndex(where: { $0.id == parentId }) {
var parent = comments[idx]
var replies = parent.replies ?? []
replies.append(created)
parent.replies = replies
comments[idx] = parent
}
replyBody = ""
replyingToId = nil
} catch let apiError as APIError {
switch apiError {
case .httpError(401, _): replyError = "You must be logged in to reply."
default: replyError = apiError.localizedDescription
}
} catch {
replyError = error.localizedDescription
}
isPostingReply = false
}
func deleteComment(commentId: String, parentId: String? = nil) async {
guard !deletingIds.contains(commentId) else { return }
deletingIds.insert(commentId)
// Optimistic removal update the UI immediately before the network call
var removedComment: BookComment?
var removedAtIndex: Int?
if let parentId {
if let idx = comments.firstIndex(where: { $0.id == parentId }) {
var parent = comments[idx]
removedComment = parent.replies?.first(where: { $0.id == commentId })
removedAtIndex = idx
parent.replies = (parent.replies ?? []).filter { $0.id != commentId }
comments[idx] = parent
}
} else {
removedAtIndex = comments.firstIndex(where: { $0.id == commentId })
removedComment = removedAtIndex.map { comments[$0] }
comments.removeAll { $0.id == commentId }
}
do {
try await APIClient.shared.deleteComment(commentId: commentId)
} catch {
// Revert the optimistic removal on failure
if let removed = removedComment {
if let parentId, let idx = removedAtIndex {
var parent = comments[idx]
var replies = parent.replies ?? []
replies.append(removed)
replies.sort { $0.created < $1.created }
parent.replies = replies
comments[idx] = parent
} else if let idx = removedAtIndex {
comments.insert(removed, at: min(idx, comments.count))
}
}
}
deletingIds.remove(commentId)
}
func vote(commentId: String, vote: String, parentId: String? = nil) 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)
if let parentId {
if let idx = comments.firstIndex(where: { $0.id == parentId }) {
var parent = comments[idx]
if let rIdx = parent.replies?.firstIndex(where: { $0.id == commentId }) {
parent.replies![rIdx] = updated
}
comments[idx] = parent
}
} else {
if let idx = comments.firstIndex(where: { $0.id == commentId }) {
var c = updated
c.replies = comments[idx].replies
comments[idx] = c
}
}
let prev = myVotes[commentId]
if prev == vote {
myVotes.removeValue(forKey: commentId)
} else {
myVotes[commentId] = vote
}
} catch {
// Silently ignore vote errors
}
}
func isVoting(_ commentId: String) -> Bool { votingIds.contains(commentId) }
func isDeleting(_ commentId: String) -> Bool { deletingIds.contains(commentId) }
func setSort(_ newSort: CommentSortOrder) {
guard newSort != sort else { return }
sort = newSort
Task { await load() }
}
}
enum CommentSortOrder: String, CaseIterable {
case top = "top"
case new = "new"
var label: String {
switch self {
case .top: return "Top"
case .new: return "New"
}
}
}
// 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 + sort picker
HStack {
Text("Comments")
.font(.headline)
let total = vm.comments.reduce(0) { $0 + 1 + ($1.replies?.count ?? 0) }
if !vm.isLoading && total > 0 {
Text("(\(total))")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
// Sort picker
if !vm.isLoading && !vm.comments.isEmpty {
Picker("Sort", selection: Binding(
get: { vm.sort },
set: { vm.setSort($0) }
)) {
ForEach(CommentSortOrder.allCases, id: \.self) { s in
Text(s.label).tag(s)
}
}
.pickerStyle(.segmented)
.frame(width: 120)
}
}
.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
commentThread(comment: comment)
Divider().padding(.leading, 16)
}
}
Color.clear.frame(height: 16)
}
.task { await vm.load() }
}
// MARK: - Comment thread (top-level + replies)
@ViewBuilder
private func commentThread(comment: BookComment) -> some View {
VStack(alignment: .leading, spacing: 0) {
CommentRow(
comment: comment,
myVote: vm.myVotes[comment.id],
isVoting: vm.isVoting(comment.id),
isDeleting: vm.isDeleting(comment.id),
isOwner: authStore.user?.id == comment.userId,
isLoggedIn: authStore.isAuthenticated,
isReplyingTo: vm.replyingToId == comment.id,
avatarUrl: vm.avatarUrls[comment.userId],
onVote: { v in Task { await vm.vote(commentId: comment.id, vote: v) } },
onDelete: { Task { await vm.deleteComment(commentId: comment.id) } },
onReply: {
if vm.replyingToId == comment.id {
vm.replyingToId = nil
vm.replyBody = ""
vm.replyError = nil
} else {
vm.replyingToId = comment.id
vm.replyBody = ""
vm.replyError = nil
}
}
)
// Inline reply form
if vm.replyingToId == comment.id {
replyForm(parentId: comment.id)
.padding(.leading, 32)
.padding(.trailing, 16)
.padding(.bottom, 8)
}
// Replies
if let replies = comment.replies, !replies.isEmpty {
VStack(alignment: .leading, spacing: 0) {
ForEach(replies) { reply in
CommentRow(
comment: reply,
myVote: vm.myVotes[reply.id],
isVoting: vm.isVoting(reply.id),
isDeleting: vm.isDeleting(reply.id),
isOwner: authStore.user?.id == reply.userId,
isLoggedIn: authStore.isAuthenticated,
isReplyingTo: false,
isReply: true,
avatarUrl: vm.avatarUrls[reply.userId],
onVote: { v in Task { await vm.vote(commentId: reply.id, vote: v, parentId: comment.id) } },
onDelete: { Task { await vm.deleteComment(commentId: reply.id, parentId: comment.id) } },
onReply: nil
)
if reply.id != replies.last?.id {
Divider().padding(.leading, 48)
}
}
}
.padding(.leading, 24)
.overlay(alignment: .leading) {
Rectangle()
.fill(Color(.systemGray4))
.frame(width: 2)
.padding(.leading, 16)
.padding(.vertical, 4)
}
}
}
}
// MARK: - Reply form
@ViewBuilder
private func replyForm(parentId: String) -> some View {
VStack(alignment: .leading, spacing: 6) {
ZStack(alignment: .topLeading) {
if vm.replyBody.isEmpty {
Text("Write a reply…")
.font(.caption)
.foregroundStyle(.tertiary)
.padding(.top, 6)
.padding(.leading, 4)
}
TextEditor(text: $vm.replyBody)
.font(.caption)
.frame(minHeight: 56, maxHeight: 120)
.scrollContentBackground(.hidden)
}
.padding(8)
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 8))
HStack {
let count = vm.replyBody.count
Text("\(count)/2000")
.font(.caption2)
.monospacedDigit()
.foregroundStyle(count > 2000 ? Color.red : Color.secondary)
Spacer()
if let err = vm.replyError {
Text(err).font(.caption2).foregroundStyle(.red).lineLimit(1)
}
Button("Cancel") {
vm.replyingToId = nil
vm.replyBody = ""
vm.replyError = nil
}
.font(.caption)
.foregroundStyle(.secondary)
Button {
Task { await vm.postReply(parentId: parentId) }
} label: {
if vm.isPostingReply {
ProgressView().controlSize(.mini)
} else {
Text("Reply").fontWeight(.semibold).font(.caption)
}
}
.buttonStyle(.borderedProminent)
.tint(.amber)
.controlSize(.mini)
.disabled(vm.isPostingReply || vm.replyBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.replyBody.count > 2000)
}
}
}
// MARK: - Post form
@ViewBuilder
private var postForm: some View {
if authStore.isAuthenticated {
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 ? Color.red : Color.secondary)
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)
}
}
} else {
Text("Log in to leave a comment.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
// 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 isDeleting: Bool
let isOwner: Bool
let isLoggedIn: Bool
let isReplyingTo: Bool
var isReply: Bool = false
var avatarUrl: String? = nil
let onVote: (String) -> Void
let onDelete: () -> Void
let onReply: (() -> Void)?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
// Avatar + Username + date
HStack(spacing: 8) {
avatarView
NavigationLink(value: NavDestination.userProfile(comment.username.isEmpty ? "" : comment.username)) {
Text(comment.username.isEmpty ? "Anonymous" : comment.username)
.font(isReply ? .caption.weight(.medium) : .subheadline.weight(.medium))
.foregroundStyle(.primary)
}
.buttonStyle(.plain)
.disabled(comment.username.isEmpty)
Text("·")
.foregroundStyle(.tertiary)
Text(formattedDate(comment.created))
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
}
// Body
Text(comment.body)
.font(isReply ? .caption : .subheadline)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
// Actions
HStack(spacing: 14) {
// 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)
// Reply button (top-level only, logged in)
if let onReply, isLoggedIn {
Button { onReply() } label: {
HStack(spacing: 3) {
Image(systemName: "arrowshape.turn.up.left")
.font(.caption)
Text("Reply")
.font(.caption)
}
.foregroundStyle(isReplyingTo ? Color.amber : .secondary)
}
}
Spacer()
// Delete (owner only)
if isOwner {
Button(role: .destructive) { onDelete() } label: {
Image(systemName: "trash")
.font(.caption)
}
.disabled(isDeleting)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.opacity(isDeleting ? 0.5 : 1)
.animation(.easeInOut(duration: 0.15), value: isDeleting)
}
private var avatarSize: CGFloat { isReply ? 20 : 24 }
@ViewBuilder
private var avatarView: some View {
if let url = avatarUrl, let imageUrl = URL(string: url) {
AsyncImage(url: imageUrl) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
default:
initialsView
}
}
.frame(width: avatarSize, height: avatarSize)
.clipShape(Circle())
} else {
initialsView
}
}
private var initialsView: some View {
let name = comment.username.isEmpty ? "?" : comment.username
let letters = String(name.prefix(2)).uppercased()
return ZStack {
Circle()
.fill(Color(.systemGray4))
.frame(width: avatarSize, height: avatarSize)
Text(letters)
.font(.system(size: avatarSize * 0.42, weight: .semibold))
.foregroundStyle(.secondary)
}
}
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,10 +91,10 @@ 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()
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
AvatarToolbarButton()
}
}
.sheet(isPresented: $showFilters) {
@@ -107,25 +108,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,55 +7,93 @@ struct HomeView: View {
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 28) {
VStack(alignment: .leading, spacing: 0) {
// Stats bar
if let stats = vm.stats {
HStack(spacing: 0) {
StatCell(value: "\(stats.totalBooks)", label: "Books")
Divider().frame(height: 32)
StatCell(value: "\(stats.totalChapters)", label: "Chapters")
Divider().frame(height: 32)
StatCell(value: "\(stats.booksInProgress)", label: "In Progress")
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
}
// Continue reading
// Continue reading all in-progress books as a horizontal shelf (Apple Books style)
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)
ShelfHeader(title: "Continue Reading")
.padding(.top, 8)
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 16) {
ForEach(vm.continueReading) { item in
NavigationLink(value: NavDestination.chapter(item.book.slug, item.chapter)) {
ContinueReadingCard(item: item)
}
.buttonStyle(.plain)
.contextMenu {
ContinueReadingContextMenu(
item: item,
onMarkFinished: {
Task { await markAsFinished(item.book) }
},
onRemove: {
Task { await removeFromLibrary(item.book.slug) }
}
)
}
}
.buttonStyle(.plain)
}
.padding(.horizontal)
.padding(.bottom, 4)
}
.padding(.horizontal)
}
.padding(.bottom, 28)
}
// Recently updated
// Stats strip
if let stats = vm.stats {
StatsStrip(stats: stats)
.padding(.horizontal)
.padding(.bottom, 28)
}
// 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)
.contextMenu {
ShareLink(item: shareURL(for: book)) {
Label("Share", systemImage: "square.and.arrow.up")
}
}
}
.buttonStyle(.plain)
}
.padding(.horizontal)
.padding(.bottom, 4)
}
.padding(.horizontal)
.padding(.bottom, 28)
}
// Subscription feed shelf
if !vm.subscriptionFeed.isEmpty {
ShelfHeader(title: "From People You Follow")
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 14) {
ForEach(vm.subscriptionFeed) { item in
NavigationLink(value: NavDestination.book(item.book.slug)) {
SubscriptionFeedCard(item: item)
}
.buttonStyle(.plain)
.contextMenu {
ShareLink(item: shareURL(for: item.book)) {
Label("Share", systemImage: "square.and.arrow.up")
}
}
}
}
.padding(.horizontal)
.padding(.bottom, 4)
}
.padding(.bottom, 28)
}
// Empty state
if vm.continueReading.isEmpty && vm.recentlyUpdated.isEmpty && !vm.isLoading {
if vm.continueReading.isEmpty && vm.recentlyUpdated.isEmpty && vm.subscriptionFeed.isEmpty && !vm.isLoading {
EmptyStateView(
icon: "books.vertical",
title: "Your library is empty",
@@ -71,78 +108,265 @@ 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)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
AvatarToolbarButton()
}
}
}
}
private func markAsFinished(_ book: Book) async {
do {
try await APIClient.shared.setProgress(slug: book.slug, chapter: book.totalChapters)
await vm.load() // Refresh home
} catch {
vm.error = error.localizedDescription
}
}
private func removeFromLibrary(_ slug: String) async {
do {
try await APIClient.shared.deleteProgress(slug: slug)
await vm.load() // Refresh home
} catch {
vm.error = error.localizedDescription
}
}
private func shareURL(for book: Book) -> URL {
let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
?? "https://v2.libnovel.kalekber.cc"
return URL(string: "\(baseURL)/books/\(book.slug)")!
}
}
// MARK: - Shelf header
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 (Apple Books style)
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: 8) {
// Cover
ZStack(alignment: .bottomLeading) {
AsyncCoverImage(url: item.book.cover)
.frame(width: 130, height: 188)
.clipShape(RoundedRectangle(cornerRadius: 10))
.shadow(color: .black.opacity(0.18), radius: 6, y: 3)
.bookCoverZoomSource(slug: item.book.slug)
// "Continue" pill badge at bottom-left
HStack(spacing: 4) {
Image(systemName: "play.fill")
.font(.system(size: 8, weight: .bold))
Text("Ch.\(item.chapter)")
.font(.system(size: 10, weight: .bold))
}
.foregroundStyle(.black.opacity(0.85))
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(Capsule().fill(Color.amber))
.padding(8)
}
// Title
Text(item.book.title)
.font(.caption.bold())
.lineLimit(2)
.frame(width: 130, alignment: .leading)
.foregroundStyle(.primary)
// Progress bar show at least a 4pt sliver so early chapters aren't invisible
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule()
.fill(Color.secondary.opacity(0.2))
Capsule()
.fill(Color.amber.opacity(0.85))
.frame(width: max(4, geo.size.width * progressFraction))
}
}
.frame(width: 130, height: 3)
// Percent label floor at 1% so early chapters don't display "0%"
Text("\(max(1, Int(progressFraction * 100)))% complete")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(width: 130)
}
}
// 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: - Supporting components
// MARK: - Horizontal shelf: subscription feed card
private struct StatCell: View {
private struct SubscriptionFeedCard: View {
let item: SubscriptionFeedItem
var body: some View {
VStack(alignment: .leading, spacing: 6) {
AsyncCoverImage(url: item.book.cover)
.frame(width: 110, height: 158)
.clipShape(RoundedRectangle(cornerRadius: 8))
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
.bookCoverZoomSource(slug: item.book.slug)
Text(item.book.title)
.font(.caption.bold())
.lineLimit(2)
.frame(width: 110, alignment: .leading)
// Tappable "via @username" attribution
NavigationLink(value: NavDestination.userProfile(item.readerUsername)) {
Text("via @\(item.readerUsername)")
.font(.caption2)
.foregroundStyle(Color.amber)
.lineLimit(1)
.frame(width: 110, alignment: .leading)
}
.buttonStyle(.plain)
}
}
}
// MARK: - Stats strip (compact inline)
private struct StatsStrip: View {
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: 2) {
Text(value).font(.title2.bold()).foregroundStyle(.primary)
Text(label).font(.caption).foregroundStyle(.secondary)
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)
}
}
private struct SectionHeader: View {
let title: String
var body: some View {
Text(title)
.font(.title3.bold())
.padding(.horizontal)
}
}
// MARK: - Context menus
private struct ContinueReadingCard: View {
private struct ContinueReadingContextMenu: View {
let item: ContinueReadingItem
let onMarkFinished: () -> Void
let onRemove: () -> Void
private var isFinished: Bool {
guard item.book.totalChapters > 0 else { return false }
return item.chapter >= item.book.totalChapters
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
KFImage(URL(string: item.book.cover))
.resizable()
.placeholder { coverPlaceholder }
.scaledToFill()
.frame(width: 120, height: 170)
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(alignment: .bottomTrailing) {
Text("Ch.\(item.chapter)")
.font(.caption2.bold())
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(.ultraThinMaterial, in: Capsule())
.padding(6)
Group {
// Share book
ShareLink(item: shareURL) {
Label("Share", systemImage: "square.and.arrow.up")
}
Divider()
// Mark as finished (only show if not already finished)
if !isFinished {
Button {
onMarkFinished()
} label: {
Label("Mark as Finished", systemImage: "checkmark.circle")
}
Text(item.book.title)
.font(.caption.bold())
.lineLimit(2)
.frame(width: 120, alignment: .leading)
}
Divider()
// Remove from library (destructive)
Button(role: .destructive) {
onRemove()
} label: {
Label("Remove from Library", systemImage: "trash")
}
}
}
private var coverPlaceholder: some View {
RoundedRectangle(cornerRadius: 10)
.fill(Color(.systemGray5))
.frame(width: 120, height: 170)
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
private var shareURL: URL {
let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
?? "https://v2.libnovel.kalekber.cc"
return URL(string: "\(baseURL)/books/\(item.book.slug)")!
}
}

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,310 @@ 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 {
// 2-column grid (matches Discover)
LazyVGrid(
columns: [
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12)
],
spacing: 16
) {
ForEach(filtered) { item in
NavigationLink(value: NavDestination.book(item.book.slug)) {
LibraryBookCard(item: item)
}
.buttonStyle(.plain)
.contextMenu {
BookContextMenu(
book: item.book,
isFinished: isCompleted(item),
onMarkFinished: {
Task {
await markAsFinished(item.book)
}
},
onRemove: {
Task {
await removeFromLibrary(item.book.slug)
}
}
)
}
}
}
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 24)
}
}
.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)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
AvatarToolbarButton()
}
}
}
}
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 func markAsFinished(_ book: Book) async {
do {
try await APIClient.shared.setProgress(slug: book.slug, chapter: book.totalChapters)
await vm.load() // Refresh library
} catch {
vm.error = error.localizedDescription
}
}
private func removeFromLibrary(_ slug: String) async {
do {
try await APIClient.shared.deleteProgress(slug: slug)
await vm.load() // Refresh library
} catch {
vm.error = error.localizedDescription
}
}
}
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())
.font(.subheadline.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(.caption)
.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)
}
}
}
// MARK: - Book context menu
private struct BookContextMenu: View {
let book: Book
let isFinished: Bool
let onMarkFinished: () -> Void
let onRemove: () -> Void
var body: some View {
Group {
// Share book
ShareLink(item: shareURL) {
Label("Share", systemImage: "square.and.arrow.up")
}
Divider()
// Mark as finished (only show if not already finished)
if !isFinished {
Button {
onMarkFinished()
} label: {
Label("Mark as Finished", systemImage: "checkmark.circle")
}
}
Divider()
// Remove from library (destructive)
Button(role: .destructive) {
onRemove()
} label: {
Label("Remove from Library", systemImage: "trash")
}
}
}
private var shareURL: URL {
// Share the book detail page URL
let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
?? "https://v2.libnovel.kalekber.cc"
return URL(string: "\(baseURL)/books/\(book.slug)")!
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,341 @@
import SwiftUI
import PhotosUI
import Kingfisher
// MARK: - AvatarNavButton
// Drop this into any NavigationStack toolbar to get an avatar button that opens the account sheet.
//
// Usage:
// .toolbar { AvatarToolbarButton() }
struct AvatarToolbarButton: View {
@EnvironmentObject private var authStore: AuthStore
@State private var showAccount = false
var body: some View {
Button {
showAccount = true
} label: {
AvatarThumb(urlString: authStore.user?.avatarURL, size: 30)
}
.sheet(isPresented: $showAccount) {
AccountMenuSheet()
}
}
}
// MARK: - AvatarThumb
// Reusable small circular avatar (used by both toolbar button and the sheet header).
struct AvatarThumb: View {
let urlString: String?
let size: CGFloat
var body: some View {
Group {
if let str = urlString, let url = URL(string: str) {
KFImage(url)
.placeholder { placeholderCircle }
.resizable()
.scaledToFill()
} else {
placeholderCircle
}
}
.frame(width: size, height: size)
.clipShape(Circle())
.overlay(Circle().stroke(Color.amber.opacity(0.6), lineWidth: 1.5))
}
private var placeholderCircle: some View {
Circle()
.fill(Color(.systemGray4))
.overlay(
Image(systemName: "person.fill")
.font(.system(size: size * 0.5))
.foregroundStyle(Color.amber)
)
}
}
// MARK: - AccountMenuSheet
struct AccountMenuSheet: View {
@EnvironmentObject private var authStore: AuthStore
@StateObject private var vm = ProfileViewModel()
@Environment(\.dismiss) private var dismiss
@State private var showChangePassword = false
// Avatar upload
@State private var photoPickerItem: PhotosPickerItem?
@State private var pendingCropImage: UIImage?
@State private var avatarURL: String? = nil
@State private var avatarUploading = false
@State private var avatarError: String?
var body: some View {
NavigationStack {
List {
// User header
Section {
HStack(spacing: 16) {
avatarPicker
VStack(alignment: .leading, spacing: 3) {
Text(authStore.user?.username ?? "")
.font(.headline)
Text(authStore.user?.role.capitalized ?? "")
.font(.caption)
.foregroundStyle(.secondary)
if let err = avatarError {
Text(err)
.font(.caption2)
.foregroundStyle(.red)
}
}
}
.padding(.vertical, 6)
}
// Reading settings
Section("Reading Settings") {
voicePicker
speedSlider
Toggle("Auto-advance chapter", isOn: Binding(
get: { authStore.settings.autoNext },
set: { newVal in
Task {
var s = authStore.settings
s.autoNext = newVal
await authStore.saveSettings(s)
}
}
))
.tint(.amber)
}
// Sessions
Section("Active Sessions") {
if vm.sessionsLoading {
ProgressView()
} else {
ForEach(vm.sessions) { session in
SessionRow(session: session) {
Task { await vm.revokeSession(id: session.id) }
}
}
}
}
// Account
Section("Account") {
Button("Change Password") { showChangePassword = true }
Button("Sign Out", role: .destructive) {
dismiss()
Task { await authStore.logout() }
}
}
}
.navigationTitle("Account")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
.fontWeight(.semibold)
}
}
.task { await vm.loadSessions() }
.sheet(isPresented: $showChangePassword) {
ChangePasswordView()
}
.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)
}
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
// MARK: - Avatar upload
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
}
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
await authStore.validateToken()
} catch {
avatarError = "Upload failed: \(error.localizedDescription)"
}
}
// MARK: - Avatar picker
@ViewBuilder
private var avatarPicker: some View {
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 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) }
}
}
// MARK: - Voice picker
@ViewBuilder
private var voicePicker: some View {
Picker("TTS Voice", selection: Binding(
get: { authStore.settings.voice },
set: { newVoice in
Task {
var s = authStore.settings
s.voice = newVoice
await authStore.saveSettings(s)
}
}
)) {
if vm.voices.isEmpty {
Text("Default").tag("af_bella")
} else {
ForEach(vm.voices, id: \.self) { v in
Text(v).tag(v)
}
}
}
.task { await vm.loadVoices() }
}
// MARK: - Speed slider
@ViewBuilder
private var speedSlider: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Playback Speed")
Spacer()
Text("\(authStore.settings.speed, specifier: "%.1f")×")
.foregroundStyle(.secondary)
}
Slider(
value: Binding(
get: { authStore.settings.speed },
set: { newSpeed in
Task {
var s = authStore.settings
s.speed = newSpeed
await authStore.saveSettings(s)
}
}
),
in: 0.5...2.0, step: 0.25
)
.tint(.amber)
}
}
}
// MARK: - Session row (local copy mirrors ProfileView.SessionRow)
private struct SessionRow: View {
let session: UserSession
let onRevoke: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "iphone")
Text(session.userAgent.isEmpty ? "Unknown device" : session.userAgent)
.font(.subheadline)
.lineLimit(1)
Spacer()
if session.isCurrent {
Text("This device")
.font(.caption2.bold())
.foregroundStyle(.amber)
} else {
Button("Revoke", role: .destructive, action: onRevoke)
.font(.caption)
}
}
Text("Last seen: \(session.lastSeen.prefix(10))")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
// MARK: - CropImageItem (Identifiable wrapper for the sheet)
private struct CropImageItem: Identifiable {
let id = UUID()
let image: UIImage
}

View File

@@ -0,0 +1,270 @@
import SwiftUI
// MARK: - AvatarCropView
// A sheet that lets the user pan and pinch a photo to fill a 1:1 circular 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 circle diameter (points)
private let cropSize: CGFloat = 280
// Pan/zoom state all in screen points, relative to the image's natural fill-fitted frame
@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
// Container size captured from GeometryReader
@State private var containerSize: 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, anchor: .center)
.offset(offset)
.gesture(
SimultaneousGesture(
MagnificationGesture()
.onChanged { value in
let proposed = lastScale * value
scale = max(minScale(in: geo.size), proposed)
}
.onEnded { _ in
lastScale = scale
clampOffset(in: geo.size)
lastOffset = offset
},
DragGesture()
.onChanged { value in
let proposed = CGSize(
width: lastOffset.width + value.translation.width,
height: lastOffset.height + value.translation.height
)
offset = clampedOffset(proposed, in: geo.size)
}
.onEnded { _ in
lastOffset = offset
}
)
)
.clipped()
// Dim overlay with transparent crop circle cut out
CropOverlay(cropSize: cropSize, containerSize: geo.size)
.allowsHitTesting(false)
}
.onAppear {
containerSize = geo.size
fitImageInitially(in: geo.size)
}
}
.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)
}
}
// MARK: - Initial fit
private func fitImageInitially(in size: CGSize) {
// The image is displayed with .scaledToFill() in the container (size).
// That means one dimension equals the container and the other overflows.
// We want the image to be just large enough that the crop circle is fully
// covered i.e. the fill-fitted image's shorter displayed dimension >= cropSize.
//
// .scaledToFill fills the container, so the image already covers the container.
// The minimum scale that covers the crop square is therefore 1.0 (image already
// fills container which is >= cropSize on both axes).
// We keep scale = 1.0 and centre the offset.
scale = 1.0
lastScale = 1.0
offset = .zero
lastOffset = .zero
}
// MARK: - Clamping helpers
/// Minimum scale: the image (at .scaledToFill in container) must cover the crop square.
/// At scale=1 the image already fills the container; cropSize <= container dimension,
/// so 1.0 is always sufficient. We cap at 1.0 to prevent zooming out below fill.
private func minScale(in containerSize: CGSize) -> CGFloat {
return 1.0
}
/// The displayed (fill-fitted) image size in the container at the given user scale.
private func displayedImageSize(in containerSize: CGSize, userScale: CGFloat) -> CGSize {
let imgAspect = image.size.width / image.size.height
let containerAspect = containerSize.width / containerSize.height
// .scaledToFill base size before user scale
let baseWidth: CGFloat
let baseHeight: CGFloat
if imgAspect > containerAspect {
// image is wider height fills container
baseHeight = containerSize.height
baseWidth = baseHeight * imgAspect
} else {
// image is taller width fills container
baseWidth = containerSize.width
baseHeight = baseWidth / imgAspect
}
return CGSize(width: baseWidth * userScale, height: baseHeight * userScale)
}
/// Maximum offset so the crop square is always covered by the image.
private func clampedOffset(_ proposed: CGSize, in containerSize: CGSize) -> CGSize {
let displayed = displayedImageSize(in: containerSize, userScale: scale)
// Half of how much the image overflows the container on each axis
let maxX = max(0, (displayed.width - cropSize) / 2)
let maxY = max(0, (displayed.height - cropSize) / 2)
return CGSize(
width: min(maxX, max(-maxX, proposed.width)),
height: min(maxY, max(-maxY, proposed.height))
)
}
private func clampOffset(in containerSize: CGSize) {
offset = clampedOffset(offset, in: containerSize)
}
// MARK: - Crop
private func confirmCrop() {
let size = containerSize.width > 0 ? containerSize : CGSize(width: 390, height: 844)
let outputSize = CGSize(width: 400, height: 400)
// --- Step 1: compute the fill-fitted base display size ---
let imgAspect = image.size.width / image.size.height
let containerAspect = size.width / size.height
let baseDisplayW: CGFloat
let baseDisplayH: CGFloat
if imgAspect > containerAspect {
baseDisplayH = size.height
baseDisplayW = baseDisplayH * imgAspect
} else {
baseDisplayW = size.width
baseDisplayH = baseDisplayW / imgAspect
}
// Displayed image size after user zoom
let displayW = baseDisplayW * scale
let displayH = baseDisplayH * scale
// --- Step 2: the crop square centre is the container centre ---
// The image centre (after offset) in container coords:
let imageCentreX = size.width / 2 + offset.width
let imageCentreY = size.height / 2 + offset.height
// Top-left of the crop square in container coords:
let cropOriginX = (size.width - cropSize) / 2
let cropOriginY = (size.height - cropSize) / 2
// Top-left of the crop square relative to the image's top-left in display space:
let imageOriginX = imageCentreX - displayW / 2
let imageOriginY = imageCentreY - displayH / 2
let cropInImageX = cropOriginX - imageOriginX // pixels in display space
let cropInImageY = cropOriginY - imageOriginY
// --- Step 3: convert display-space coords to image pixel coords ---
let displayToPixelX = image.size.width / displayW
let displayToPixelY = image.size.height / displayH
let pixelX = cropInImageX * displayToPixelX
let pixelY = cropInImageY * displayToPixelY
let pixelW = cropSize * displayToPixelX
let pixelH = cropSize * displayToPixelY
let cropRect = CGRect(x: pixelX, y: pixelY, width: pixelW, height: pixelH)
.intersection(CGRect(origin: .zero, size: image.size))
guard cropRect.width > 0, cropRect.height > 0 else {
// Fallback: use entire image
if let jpeg = image.jpegData(compressionQuality: 0.9) { onConfirm(jpeg) }
return
}
// --- Step 4: render cropped region into 400×400 ---
let renderer = UIGraphicsImageRenderer(size: outputSize)
let cropped = renderer.image { _ in
// Draw only the cropRect portion of the image scaled to fill outputSize
let destRect = CGRect(origin: .zero, size: outputSize)
// UIImage.draw(in:) draws the full image; we use CGImage cropping instead
if let cgImg = image.cgImage?.cropping(to: cropRect) {
let croppedUI = UIImage(cgImage: cgImg, scale: image.scale, orientation: image.imageOrientation)
croppedUI.draw(in: destRect)
} else {
image.draw(in: destRect)
}
}
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 circle 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,43 @@
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) {
avatarPicker
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 +54,7 @@ struct ProfileView: View {
.tint(.amber)
}
// Sessions
// Sessions
Section("Active Sessions") {
if vm.sessionsLoading {
ProgressView()
@@ -55,7 +67,7 @@ struct ProfileView: View {
}
}
// Account
// Account
Section("Account") {
Button("Change Password") { showChangePassword = true }
Button("Sign Out", role: .destructive) {
@@ -64,13 +76,114 @@ 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)"
}
}
// MARK: - Avatar picker
@ViewBuilder
private var avatarPicker: some View {
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) }
}
}
@@ -121,7 +234,7 @@ struct ProfileView: View {
}
}
),
in: 0.5...3.0, step: 0.25
in: 0.5...2.0, step: 0.25
)
.tint(.amber)
}
@@ -208,7 +321,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 +330,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,209 @@
import SwiftUI
struct UserProfileView: View {
let username: String
@StateObject private var vm: UserProfileViewModel
@EnvironmentObject private var authStore: AuthStore
init(username: String) {
self.username = username
_vm = StateObject(wrappedValue: UserProfileViewModel(username: username))
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
if vm.isLoading && vm.profile == nil {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.top, 60)
} else if let profile = vm.profile {
profileHeader(profile)
.padding(.bottom, 28)
if !vm.currentlyReading.isEmpty {
ShelfHeader(title: "Currently Reading")
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 14) {
ForEach(vm.currentlyReading) { item in
NavigationLink(value: NavDestination.book(item.book.slug)) {
ProfileBookCard(item: item)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
.padding(.bottom, 4)
}
.padding(.bottom, 28)
}
if !vm.library.isEmpty {
ShelfHeader(title: "Library")
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 14) {
ForEach(vm.library) { item in
NavigationLink(value: NavDestination.book(item.book.slug)) {
ProfileBookCard(item: item)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
.padding(.bottom, 4)
}
.padding(.bottom, 28)
}
if vm.currentlyReading.isEmpty && vm.library.isEmpty && !vm.isLoading {
EmptyStateView(
icon: "books.vertical",
title: "No books yet",
message: "\(username) hasn't read anything yet."
)
.frame(maxWidth: .infinity)
.padding(.top, 20)
}
} else if let err = vm.error {
EmptyStateView(icon: "exclamationmark.triangle", title: "Error", message: err)
.frame(maxWidth: .infinity)
.padding(.top, 60)
}
Color.clear.frame(height: 20)
}
}
.navigationTitle("@\(username)")
.navigationBarTitleDisplayMode(.inline)
.task { await vm.load() }
.refreshable { await vm.load() }
.errorAlert($vm.error)
}
// MARK: - Profile header
@ViewBuilder
private func profileHeader(_ profile: PublicUserProfile) -> some View {
VStack(alignment: .center, spacing: 16) {
AvatarThumb(urlString: profile.avatarUrl, size: 80)
VStack(spacing: 4) {
Text("@\(profile.username)")
.font(.title3.bold())
if !profile.created.isEmpty {
Text("Joined \(shortDate(profile.created))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
// Stats row
HStack(spacing: 32) {
VStack(spacing: 2) {
Text("\(profile.followerCount)")
.font(.subheadline.bold().monospacedDigit())
Text("Followers")
.font(.caption2)
.foregroundStyle(.secondary)
}
VStack(spacing: 2) {
Text("\(profile.followingCount)")
.font(.subheadline.bold().monospacedDigit())
Text("Following")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
// Follow button only shown for other users (not self)
if !profile.isSelf && authStore.isAuthenticated {
Button {
Task { await vm.toggleSubscribe() }
} label: {
if vm.isTogglingSubscribe {
ProgressView().controlSize(.small)
.frame(width: 120, height: 34)
} else if profile.isSubscribed {
Label("Following", systemImage: "checkmark")
.font(.subheadline.bold())
.frame(width: 120, height: 34)
} else {
Text("Follow")
.font(.subheadline.bold())
.frame(width: 120, height: 34)
}
}
.buttonStyle(.borderedProminent)
.tint(profile.isSubscribed ? Color(.systemGray4) : .amber)
.disabled(vm.isTogglingSubscribe)
}
}
.frame(maxWidth: .infinity)
.padding(.top, 24)
.padding(.horizontal)
}
private func shortDate(_ iso: String) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ"
if let date = formatter.date(from: iso) {
let out = DateFormatter()
out.dateStyle = .medium
out.timeStyle = .none
return out.string(from: date)
}
return String(iso.prefix(10))
}
}
// MARK: - Shelf header
private struct ShelfHeader: View {
let title: String
var body: some View {
Text(title)
.font(.title3.bold())
.padding(.horizontal)
.padding(.bottom, 10)
}
}
// MARK: - Book card for profile shelves
private struct ProfileBookCard: View {
let item: PublicLibraryItem
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ZStack(alignment: .bottomLeading) {
AsyncCoverImage(url: item.book.cover)
.frame(width: 110, height: 158)
.clipShape(RoundedRectangle(cornerRadius: 8))
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
// Chapter badge (if reading)
if let ch = item.lastChapter, ch > 0 {
Text("Ch.\(ch)")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.black.opacity(0.85))
.padding(.horizontal, 7)
.padding(.vertical, 4)
.background(Capsule().fill(Color.amber))
.padding(6)
}
}
Text(item.book.title)
.font(.caption.bold())
.lineLimit(2)
.frame(width: 110, alignment: .leading)
Text(item.book.author)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.frame(width: 110, alignment: .leading)
}
}
}

View File

@@ -0,0 +1,247 @@
import SwiftUI
// MARK: - SearchView
// Dedicated search tab modelled after Apple Books' Search screen.
// Shows a prominent search bar; while idle displays recent searches and
// trending/popular novels; after a query shows a results grid.
struct SearchView: View {
@StateObject private var vm = SearchViewModel()
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Search bar
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search novels, authors…", text: $vm.query)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.search)
.onSubmit { vm.submitSearch() }
if !vm.query.isEmpty {
Button { vm.clear() } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
}
}
.padding(10)
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 12)
Divider()
// Content
if vm.query.isEmpty && vm.results.isEmpty {
idleContent
} else if vm.isLoading {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if vm.results.isEmpty {
EmptyStateView(
icon: "magnifyingglass",
title: "No results",
message: "Try a different title or author name."
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
resultsGrid
}
}
.navigationTitle("Search")
.appNavigationDestination()
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
AvatarToolbarButton()
}
}
}
}
// MARK: - Idle screen (recent searches + popular)
@ViewBuilder
private var idleContent: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
// Recent searches
if !vm.recentSearches.isEmpty {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Recent")
.font(.title3.bold())
Spacer()
Button("Clear") { vm.clearRecent() }
.font(.subheadline)
.foregroundStyle(.amber)
}
.padding(.horizontal)
.padding(.bottom, 10)
ForEach(vm.recentSearches, id: \.self) { term in
Button {
vm.query = term
vm.submitSearch()
} label: {
HStack {
Image(systemName: "clock")
.foregroundStyle(.secondary)
.frame(width: 20)
Text(term)
.foregroundStyle(.primary)
Spacer()
Image(systemName: "arrow.up.left")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal)
.padding(.vertical, 11)
}
Divider().padding(.leading, 44)
}
}
}
// Popular / trending novels (loaded from browse popular)
if !vm.popular.isEmpty {
VStack(alignment: .leading, spacing: 10) {
Text("Popular")
.font(.title3.bold())
.padding(.horizontal)
LazyVGrid(
columns: [
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12)
],
spacing: 16
) {
ForEach(vm.popular) { novel in
NavigationLink(value: NavDestination.book(novel.slug)) {
SearchNovelCard(novel: novel)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
}
}
Color.clear.frame(height: 20)
}
.padding(.top, 16)
}
}
// MARK: - Results grid
@ViewBuilder
private var resultsGrid: some View {
ScrollView {
LazyVGrid(
columns: [
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12)
],
spacing: 16
) {
ForEach(vm.results) { novel in
NavigationLink(value: NavDestination.book(novel.slug)) {
SearchNovelCard(novel: novel)
}
.buttonStyle(.plain)
}
}
.padding()
}
}
}
// MARK: - Search novel card (compact 2-column)
private struct SearchNovelCard: View {
let novel: BrowseNovel
var body: some View {
VStack(alignment: .leading, spacing: 6) {
AsyncCoverImage(url: novel.cover)
.frame(maxWidth: .infinity)
.aspectRatio(2/3, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 8))
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
.bookCoverZoomSource(slug: novel.slug)
Text(novel.title)
.font(.subheadline.bold())
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
}
}
// MARK: - SearchViewModel
@MainActor
final class SearchViewModel: ObservableObject {
@Published var query: String = ""
@Published var results: [BrowseNovel] = []
@Published var popular: [BrowseNovel] = []
@Published var isLoading = false
// Persisted in UserDefaults (max 10 recent terms)
@Published var recentSearches: [String] = []
private let recentKey = "searchRecentTerms"
init() {
recentSearches = (UserDefaults.standard.stringArray(forKey: recentKey) ?? [])
Task { await loadPopular() }
}
func submitSearch() {
let term = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !term.isEmpty else { return }
saveRecent(term)
Task { await runSearch(term) }
}
func clear() {
query = ""
results = []
}
func clearRecent() {
recentSearches = []
UserDefaults.standard.removeObject(forKey: recentKey)
}
private func runSearch(_ term: String) async {
isLoading = true
do {
let result = try await APIClient.shared.search(query: term)
results = result.results
} catch {
results = []
}
isLoading = false
}
private func loadPopular() async {
do {
let result = try await APIClient.shared.browse(page: 1, genre: "all", sort: "popular", status: "all")
popular = Array(result.novels.prefix(12))
} catch {}
}
private func saveRecent(_ term: String) {
var list = recentSearches.filter { $0 != term }
list.insert(term, at: 0)
if list.count > 10 { list = Array(list.prefix(10)) }
recentSearches = list
UserDefaults.standard.set(list, forKey: recentKey)
}
}

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

@@ -9,8 +9,14 @@ RUN go mod download
COPY . .
# Build-time version info — injected by docker-compose or CI via --build-arg.
ARG VERSION=dev
ARG COMMIT=unknown
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o /scraper ./cmd/scraper
go build \
-ldflags="-s -w -X main.Version=${VERSION} -X main.Commit=${COMMIT}" \
-o /scraper ./cmd/scraper
# ── Runtime stage ──────────────────────────────────────────────────────────────
FROM alpine:3.21

View File

@@ -47,6 +47,13 @@ import (
"github.com/libnovel/scraper/internal/storage"
)
// Build-time version info — injected via -ldflags during docker build.
// Falls back to "dev" / "unknown" when built without -ldflags (local dev).
var (
Version = "dev"
Commit = "unknown"
)
func main() {
logLevel := slog.LevelInfo
if v := os.Getenv("LOG_LEVEL"); v != "" {
@@ -95,6 +102,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"),
@@ -182,7 +190,7 @@ func run(log *slog.Logger) error {
"pocketbase_url", pbCfg.BaseURL,
"pocketbase_email", pbCfg.AdminEmail,
)
srv := server.New(addr, oCfg, nf, log, store, kokoroURL, kokoroVoice)
srv := server.New(addr, oCfg, nf, log, store, kokoroURL, kokoroVoice, Version, Commit)
return srv.ListenAndServe(ctx)
case "save-browse":

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

@@ -43,6 +43,8 @@ type Server struct {
running bool
kokoroURL string // Kokoro-FastAPI base URL, e.g. http://kokoro:8880
kokoroVoice string // default voice, e.g. af_bella
version string // semver tag, e.g. "v1.2.3" (set via ldflags)
commit string // short git SHA (set via ldflags)
// voiceMu guards cachedVoices.
voiceMu sync.RWMutex
@@ -74,7 +76,7 @@ type browseCacheEntry struct {
}
// New creates a new Server.
func New(addr string, oCfg orchestrator.Config, novel scraper.NovelScraper, log *slog.Logger, store storage.Store, kokoroURL, kokoroVoice string) *Server {
func New(addr string, oCfg orchestrator.Config, novel scraper.NovelScraper, log *slog.Logger, store storage.Store, kokoroURL, kokoroVoice, version, commit string) *Server {
return &Server{
addr: addr,
oCfg: oCfg,
@@ -83,6 +85,8 @@ func New(addr string, oCfg orchestrator.Config, novel scraper.NovelScraper, log
store: store,
kokoroURL: kokoroURL,
kokoroVoice: kokoroVoice,
version: version,
commit: commit,
audioJobIDs: make(map[string]string),
browseInFlight: make(map[string]struct{}),
browseMemCache: make(map[string]browseCacheEntry),
@@ -138,6 +142,7 @@ func (s *Server) voices() []string {
func (s *Server) ListenAndServe(ctx context.Context) error {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /api/version", s.handleVersion)
mux.HandleFunc("POST /scrape", s.handleScrapeCatalogue)
mux.HandleFunc("POST /scrape/book", s.handleScrapeBook)
mux.HandleFunc("POST /scrape/book/range", s.handleScrapeBookRange)
@@ -165,6 +170,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)
@@ -220,7 +227,19 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"version": s.version,
"commit": s.commit,
})
}
func (s *Server) handleVersion(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"version": s.version,
"commit": s.commit,
})
}
// ─── Session cookie helpers ───────────────────────────────────────────────────

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,40 @@ 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": "parent_id", "type": "text"}, // empty = top-level; set = reply to that comment ID
},
},
{
"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"
},
},
{
// follower_id follows followee_id
"name": "user_subscriptions",
"type": "base",
"fields": []map[string]interface{}{
{"name": "follower_id", "type": "text", "required": true},
{"name": "followee_id", "type": "text", "required": true},
{"name": "created", "type": "date"},
},
},
}
for _, col := range collections {
name, _ := col["name"].(string)
@@ -458,6 +495,10 @@ 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"},
// parent_id enables 1-level comment nesting (replies). Empty = top-level comment.
{"book_comments", "parent_id", "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,32 @@ 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"},
{"name": "parent_id", "type": "text"}
]
}'
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

@@ -5,19 +5,23 @@ COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# Build-time version info — injected by docker-compose or CI via --build-arg.
ARG BUILD_VERSION=dev
ARG BUILD_COMMIT=unknown
# Expose as PUBLIC_ env vars so SvelteKit's $env/dynamic/public can read them.
ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
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 +29,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,116 @@
<script lang="ts">
import { 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 | undefined = $state();
let cropper: Cropper | null = null;
let objectUrl = '';
// Initialize cropper once the img element is bound and the file is known.
// Use a $effect so it runs after the DOM is ready (replaces onMount).
$effect(() => {
if (!imgEl || !file) return;
// Create the object URL and set src directly on the element (not via reactive
// state) so cropperjs sees the correct src before the image load event fires.
objectUrl = URL.createObjectURL(file);
imgEl.src = objectUrl;
// Cropperjs must be initialised inside the image's load event so it can
// measure the natural dimensions — if we call new Cropper() before the image
// has loaded, the crop canvas is blank/invisible.
const handleLoad = () => {
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
});
};
imgEl.addEventListener('load', handleLoad, { once: true });
return () => {
imgEl?.removeEventListener('load', handleLoad);
cropper?.destroy();
cropper = null;
URL.revokeObjectURL(objectUrl);
objectUrl = '';
};
});
onDestroy(() => {
cropper?.destroy();
if (objectUrl) 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 — overflow must be visible so cropperjs can
render the crop canvas outside the natural image bounds. The fixed
height gives cropperjs a stable container to size itself against. -->
<div class="rounded-xl bg-zinc-800" style="height: 300px; position: relative;">
<img
bind:this={imgEl}
alt="Crop preview"
style="display:block; max-width:100%; max-height: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,560 @@
<script lang="ts">
interface BookComment {
id: string;
slug: string;
user_id: string;
username: string;
body: string;
upvotes: number;
downvotes: number;
created: string;
parent_id?: string;
replies?: BookComment[];
}
let {
slug,
isLoggedIn = false,
currentUserId = ''
}: {
slug: string;
isLoggedIn?: boolean;
currentUserId?: string;
} = $props();
// ── State ─────────────────────────────────────────────────────────────────
let comments = $state<BookComment[]>([]);
let myVotes = $state<Record<string, 'up' | 'down'>>({});
let avatarUrls = $state<Record<string, string>>({});
let loading = $state(true);
let loadError = $state('');
// Top-level new comment
let newBody = $state('');
let posting = $state(false);
let postError = $state('');
// Sort
let sort = $state<'new' | 'top'>('top');
// Reply state: which comment is being replied to
let replyingTo = $state<string | null>(null); // comment id
let replyBody = $state('');
let replyPosting = $state(false);
let replyError = $state('');
// Delete in-flight set
let deletingIds = $state(new Set<string>());
// Per-comment vote inflight set (prevents double-clicks)
let votingIds = $state(new Set<string>());
// ── Load comments ─────────────────────────────────────────────────────────
async function loadComments() {
loading = true;
loadError = '';
try {
const res = await fetch(
`/api/comments/${encodeURIComponent(slug)}?sort=${sort}`
);
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json();
comments = data.comments ?? [];
myVotes = data.myVotes ?? {};
avatarUrls = data.avatarUrls ?? {};
} catch (e) {
loadError = 'Failed to load comments.';
} finally {
loading = false;
}
}
$effect(() => {
loadComments();
});
// Re-load when sort changes (after initial mount)
let firstLoad = true;
$effect(() => {
// Read sort to create a dependency
const _ = sort;
if (firstLoad) { firstLoad = false; return; }
loadComments();
});
// ── Post top-level 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();
created.replies = [];
// Prepend for 'new', or re-sort for 'top'
if (sort === 'new') {
comments = [created, ...comments];
} else {
comments = [created, ...comments]; // new comment has 0 score, goes to end after sort would happen
}
newBody = '';
} catch {
postError = 'Failed to post comment.';
} finally {
posting = false;
}
}
// ── Post reply ────────────────────────────────────────────────────────────
async function postReply(parentId: string) {
const text = replyBody.trim();
if (!text || replyPosting) return;
if (text.length > 2000) { replyError = 'Reply is too long (max 2000 characters).'; return; }
replyPosting = true;
replyError = '';
try {
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: text, parent_id: parentId })
});
if (res.status === 401) { replyError = 'You must be logged in to reply.'; return; }
if (!res.ok) {
const err = await res.json().catch(() => ({}));
replyError = err.message ?? 'Failed to post reply.';
return;
}
const created: BookComment = await res.json();
// Append to the parent's replies list
comments = comments.map((c) => {
if (c.id !== parentId) return c;
return { ...c, replies: [...(c.replies ?? []), created] };
});
replyBody = '';
replyingTo = null;
} catch {
replyError = 'Failed to post reply.';
} finally {
replyPosting = false;
}
}
// ── Delete ────────────────────────────────────────────────────────────────
async function deleteComment(commentId: string, parentId?: string) {
if (deletingIds.has(commentId)) return;
deletingIds = new Set([...deletingIds, commentId]);
try {
const res = await fetch(`/api/comment/${commentId}`, { method: 'DELETE' });
if (!res.ok) return;
if (parentId) {
// Remove reply from parent
comments = comments.map((c) => {
if (c.id !== parentId) return c;
return { ...c, replies: (c.replies ?? []).filter((r) => r.id !== commentId) };
});
} else {
// Remove top-level comment
comments = comments.filter((c) => c.id !== commentId);
}
} finally {
const next = new Set(deletingIds);
next.delete(commentId);
deletingIds = next;
}
}
// ── Vote ──────────────────────────────────────────────────────────────────
async function vote(commentId: string, v: 'up' | 'down', parentId?: string) {
if (votingIds.has(commentId)) return;
votingIds = new Set([...votingIds, commentId]);
try {
const res = await fetch(`/api/comment/${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 (handle both top-level and replies)
if (parentId) {
comments = comments.map((c) => {
if (c.id !== parentId) return c;
return {
...c,
replies: (c.replies ?? []).map((r) => (r.id === commentId ? updated : r))
};
});
} else {
comments = comments.map((c) => (c.id === commentId ? { ...updated, replies: c.replies } : 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 initials(username: string): string {
const name = username.trim() || '?';
return name.slice(0, 2).toUpperCase();
}
function formatDate(iso: string): string {
try {
const date = new Date(iso);
const now = Date.now();
const diffMs = now - date.getTime();
const diffMins = Math.floor(diffMs / 60_000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 30) return `${diffDays}d ago`;
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
} catch {
return iso;
}
}
const charCount = $derived(newBody.length);
const charOver = $derived(charCount > 2000);
const replyCharCount = $derived(replyBody.length);
const replyCharOver = $derived(replyCharCount > 2000);
const totalCount = $derived(
comments.reduce((n, c) => n + 1 + (c.replies?.length ?? 0), 0)
);
</script>
<div class="mt-10">
<!-- Header + sort controls -->
<div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
<h2 class="text-base font-semibold text-zinc-200">
Comments
{#if !loading && totalCount > 0}
<span class="text-zinc-500 font-normal text-sm ml-1">({totalCount})</span>
{/if}
</h2>
<!-- Sort tabs -->
{#if !loading && comments.length > 0}
<div class="flex items-center gap-1 text-xs rounded-lg bg-zinc-800/60 p-1">
<button
onclick={() => (sort = 'top')}
class="px-2.5 py-1 rounded-md transition-colors {sort === 'top'
? 'bg-zinc-700 text-zinc-100'
: 'text-zinc-500 hover:text-zinc-300'}"
>
Top
</button>
<button
onclick={() => (sort = 'new')}
class="px-2.5 py-1 rounded-md transition-colors {sort === 'new'
? 'bg-zinc-700 text-zinc-100'
: 'text-zinc-500 hover:text-zinc-300'}"
>
New
</button>
</div>
{/if}
</div>
<!-- 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)}
{@const deleting = deletingIds.has(comment.id)}
{@const isOwner = isLoggedIn && currentUserId === comment.user_id}
<div class="rounded-lg bg-zinc-800/50 border border-zinc-700/50 px-4 py-3 flex flex-col gap-2 {deleting ? 'opacity-50' : ''}">
<!-- Header -->
<div class="flex items-center gap-2 flex-wrap">
{#if avatarUrls[comment.user_id]}
<img src={avatarUrls[comment.user_id]} alt={comment.username} class="w-6 h-6 rounded-full object-cover flex-shrink-0" />
{:else}
<div class="w-6 h-6 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
<span class="text-[9px] font-semibold text-zinc-300 leading-none">{initials(comment.username)}</span>
</div>
{/if}
{#if comment.username}
<a href="/users/{comment.username}" class="text-sm font-medium text-zinc-200 hover:text-amber-400 transition-colors">{comment.username}</a>
{:else}
<span class="text-sm font-medium text-zinc-400">Anonymous</span>
{/if}
<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>
<!-- Actions row: votes + reply + delete -->
<div class="flex items-center gap-3 pt-1 flex-wrap">
<!-- 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>
<!-- Reply button -->
{#if isLoggedIn}
<button
onclick={() => {
if (replyingTo === comment.id) {
replyingTo = null;
replyBody = '';
replyError = '';
} else {
replyingTo = comment.id;
replyBody = '';
replyError = '';
}
}}
class="flex items-center gap-1 text-xs transition-colors
{replyingTo === comment.id
? '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="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
</svg>
Reply
</button>
{/if}
<!-- Delete (owner only) -->
{#if isOwner}
<button
onclick={() => deleteComment(comment.id)}
disabled={deleting}
class="flex items-center gap-1 text-xs text-zinc-600 hover:text-red-400 transition-colors disabled:opacity-50 ml-auto"
title="Delete comment"
>
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
Delete
</button>
{/if}
</div>
<!-- Inline reply form -->
{#if replyingTo === comment.id}
<div class="mt-1 flex flex-col gap-2 pl-2 border-l-2 border-zinc-700">
<textarea
bind:value={replyBody}
placeholder="Write a reply…"
rows="2"
class="w-full px-3 py-2 rounded-lg bg-zinc-900 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-2">
<span class="text-xs {replyCharOver ? 'text-red-400' : 'text-zinc-600'} tabular-nums">
{replyCharCount}/2000
</span>
<div class="flex items-center gap-2">
{#if replyError}
<span class="text-xs text-red-400">{replyError}</span>
{/if}
<button
onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }}
class="px-3 py-1 rounded-lg text-xs text-zinc-400 hover:text-zinc-200 transition-colors"
>
Cancel
</button>
<button
onclick={() => postReply(comment.id)}
disabled={replyPosting || !replyBody.trim() || replyCharOver}
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors
{replyPosting || !replyBody.trim() || replyCharOver
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300'}"
>
{replyPosting ? 'Posting…' : 'Reply'}
</button>
</div>
</div>
</div>
{/if}
<!-- Replies -->
{#if comment.replies && comment.replies.length > 0}
<div class="mt-1 flex flex-col gap-2 pl-3 border-l-2 border-zinc-700/60">
{#each comment.replies as reply (reply.id)}
{@const replyVote = myVotes[reply.id]}
{@const replyVoting = votingIds.has(reply.id)}
{@const replyDeleting = deletingIds.has(reply.id)}
{@const replyIsOwner = isLoggedIn && currentUserId === reply.user_id}
<div class="rounded-md bg-zinc-800/30 px-3 py-2.5 flex flex-col gap-1.5 {replyDeleting ? 'opacity-50' : ''}">
<!-- Reply header -->
<div class="flex items-center gap-2 flex-wrap">
{#if avatarUrls[reply.user_id]}
<img src={avatarUrls[reply.user_id]} alt={reply.username} class="w-5 h-5 rounded-full object-cover flex-shrink-0" />
{:else}
<div class="w-5 h-5 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
<span class="text-[8px] font-semibold text-zinc-300 leading-none">{initials(reply.username)}</span>
</div>
{/if}
{#if reply.username}
<a href="/users/{reply.username}" class="text-xs font-medium text-zinc-300 hover:text-amber-400 transition-colors">{reply.username}</a>
{:else}
<span class="text-xs font-medium text-zinc-400">Anonymous</span>
{/if}
<span class="text-zinc-600 text-xs">&middot;</span>
<span class="text-xs text-zinc-500">{formatDate(reply.created)}</span>
</div>
<!-- Reply body -->
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{reply.body}</p>
<!-- Reply actions -->
<div class="flex items-center gap-3 pt-0.5">
<button
onclick={() => vote(reply.id, 'up', comment.id)}
disabled={replyVoting}
title="Upvote"
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
{replyVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300'}"
>
<svg class="w-3 h-3" 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">{reply.upvotes ?? 0}</span>
</button>
<button
onclick={() => vote(reply.id, 'down', comment.id)}
disabled={replyVoting}
title="Downvote"
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
{replyVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300'}"
>
<svg class="w-3 h-3" 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">{reply.downvotes ?? 0}</span>
</button>
{#if replyIsOwner}
<button
onclick={() => deleteComment(reply.id, comment.id)}
disabled={replyDeleting}
class="flex items-center gap-1 text-xs text-zinc-600 hover:text-red-400 transition-colors disabled:opacity-50 ml-auto"
title="Delete reply"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
Delete
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</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 ─────────────────────────────────────────────────────────
@@ -126,6 +127,14 @@ async function pbPatch(path: string, body: unknown): Promise<Response> {
});
}
async function pbDelete(path: string): Promise<Response> {
const token = await getToken();
return fetch(`${PB_URL}${path}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
});
}
interface PBList<T> {
items: T[];
totalItems: number;
@@ -289,6 +298,38 @@ export async function setProgress(
}
}
/**
* Delete progress entry for a specific book (removes from library/continue reading).
*/
export async function deleteProgress(
sessionId: string,
slug: string,
userId?: string
): Promise<void> {
const existing = await listOne<Progress & { id: string }>(
'progress',
progressFilter(sessionId, slug, userId)
);
if (!existing) {
log.debug('pocketbase', 'deleteProgress: no record found', { sessionId, slug, userId });
return;
}
const res = await pbDelete(`/api/collections/progress/records/${existing.id}`);
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('pocketbase', 'deleteProgress failed', {
slug,
id: existing.id,
status: res.status,
body
});
throw new Error(`Failed to delete progress: ${res.status}`);
}
log.info('pocketbase', 'deleteProgress success', { slug, id: existing.id });
}
/**
* Merge anonymous session progress into a user account on login/register.
*
@@ -794,3 +835,515 @@ 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;
parent_id?: string; // empty / absent = top-level; set = reply
}
export interface CommentVote {
id: string;
comment_id: string;
user_id: string;
session_id: string;
vote: 'up' | 'down';
}
export type CommentSort = 'top' | 'new';
/**
* List top-level comments for a book.
* sort='top' → by net score (upvotes downvotes) desc, then newest
* sort='new' → newest first (default)
* Replies (parent_id != "") are NOT included — fetch them separately.
*/
export async function listComments(
slug: string,
sort: CommentSort = 'new'
): Promise<BookComment[]> {
const token = await getToken();
const slugEsc = slug.replace(/"/g, '\\"');
// Only top-level comments (parent_id is empty or missing)
const filter = encodeURIComponent(`slug="${slugEsc}"&&(parent_id=""||parent_id=null)`);
// PocketBase sorts: for 'top' we still fetch all and re-sort in JS because
// PocketBase doesn't support computed sort fields. For 'new' we push the
// sort down to the DB so large result sets are still paged correctly.
const pbSort = sort === 'new' ? '&sort=-created' : '&sort=-created';
const res = await fetch(
`${PB_URL}/api/collections/book_comments/records?filter=${filter}${pbSort}&perPage=200`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) return [];
const data = await res.json();
let items = (data.items ?? []) as BookComment[];
if (sort === 'top') {
items = items.sort((a, b) => {
const scoreB = (b.upvotes ?? 0) - (b.downvotes ?? 0);
const scoreA = (a.upvotes ?? 0) - (a.downvotes ?? 0);
if (scoreB !== scoreA) return scoreB - scoreA;
// tie-break: newest first
return new Date(b.created).getTime() - new Date(a.created).getTime();
});
}
return items;
}
/**
* List replies (1-level deep) for a single parent comment.
* Always sorted oldest-first so the conversation reads naturally.
*/
export async function listReplies(parentId: string): Promise<BookComment[]> {
const token = await getToken();
const filter = encodeURIComponent(`parent_id="${parentId.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.
* Pass parentId to create a reply; omit / pass undefined for a top-level comment.
*/
export async function createComment(
slug: string,
body: string,
userId: string | undefined,
username: string,
parentId?: 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,
parent_id: parentId ?? '',
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>;
}
/**
* Delete a comment (and optionally its replies) by ID.
* Only the comment owner (matched by userId) may delete.
* Throws if the comment doesn't exist or the user doesn't own it.
*/
export async function deleteComment(commentId: string, userId: string): Promise<void> {
const token = await getToken();
// Fetch the comment to verify ownership
const getRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!getRes.ok) throw new Error(`Comment not found: ${commentId}`);
const comment = (await getRes.json()) as BookComment;
if (comment.user_id !== userId) throw new Error('Not authorized to delete this comment');
// Delete any replies first
const repliesFilter = encodeURIComponent(`parent_id="${commentId.replace(/"/g, '\\"')}"`);
const repliesRes = await fetch(
`${PB_URL}/api/collections/book_comments/records?filter=${repliesFilter}&perPage=100`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (repliesRes.ok) {
const repliesData = await repliesRes.json();
const replies = (repliesData.items ?? []) as BookComment[];
await Promise.all(
replies.map((r) =>
fetch(`${PB_URL}/api/collections/book_comments/records/${r.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
})
)
);
}
// Delete the comment itself
const delRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
});
if (!delRes.ok) throw new Error(`deleteComment failed: ${delRes.status}`);
}
/**
* 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;
}
// ─── User subscriptions ───────────────────────────────────────────────────────
export interface UserSubscription {
id: string;
follower_id: string;
followee_id: string;
created: string;
}
/**
* Returns the subscription record if follower_id follows followee_id, else null.
*/
export async function getSubscription(
followerId: string,
followeeId: string
): Promise<UserSubscription | null> {
const filter = encodeURIComponent(`follower_id="${followerId}"&&followee_id="${followeeId}"`);
const res = await pbGet<{ items: UserSubscription[]; totalItems: number }>(
`/api/collections/user_subscriptions/records?filter=${filter}&perPage=1`
).catch(() => null);
return res?.items?.[0] ?? null;
}
/**
* Subscribe follower_id to followee_id. No-ops if already subscribed.
* Returns the subscription record.
*/
export async function subscribe(followerId: string, followeeId: string): Promise<void> {
const existing = await getSubscription(followerId, followeeId);
if (existing) return;
const res = await pbPost('/api/collections/user_subscriptions/records', {
follower_id: followerId,
followee_id: followeeId,
created: new Date().toISOString()
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Failed to subscribe: ${res.status}${body}`);
}
}
/**
* Unsubscribe follower_id from followee_id. No-ops if not subscribed.
*/
export async function unsubscribe(followerId: string, followeeId: string): Promise<void> {
const existing = await getSubscription(followerId, followeeId);
if (!existing) return;
const token = await getToken();
await fetch(`${PB_URL}/api/collections/user_subscriptions/records/${existing.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
});
}
/**
* Returns the list of user IDs that followerId is subscribed to.
*/
export async function getFollowingIds(followerId: string): Promise<string[]> {
const items = await listAll<UserSubscription>(
'user_subscriptions',
`follower_id="${followerId}"`,
'-created'
).catch(() => [] as UserSubscription[]);
return items.map((s) => s.followee_id);
}
/**
* Returns the count of subscribers (followers) for a given user.
*/
export async function getFollowerCount(followeeId: string): Promise<number> {
return countCollection('user_subscriptions', `followee_id="${followeeId}"`).catch(() => 0);
}
/**
* Returns the count of accounts a user is following.
*/
export async function getFollowingCount(followerId: string): Promise<number> {
return countCollection('user_subscriptions', `follower_id="${followerId}"`).catch(() => 0);
}
/**
* Public profile data for a user.
*/
export interface PublicProfile {
id: string;
username: string;
avatar_url?: string;
created: string;
followerCount: number;
followingCount: number;
}
/**
* Returns a user's public profile (no sensitive fields) by username.
*/
export async function getPublicProfile(username: string): Promise<PublicProfile | null> {
const user = await getUserByUsername(username);
if (!user) return null;
const [followerCount, followingCount] = await Promise.all([
getFollowerCount(user.id),
getFollowingCount(user.id)
]);
return {
id: user.id,
username: user.username,
avatar_url: user.avatar_url,
created: user.created,
followerCount,
followingCount
};
}
/**
* Returns a user's public library: books they have saved or are reading.
* Only includes books with progress or explicit saves (user_library).
*/
export async function getUserPublicLibrary(
userId: string
): Promise<Array<{ book: Book; chapter: number | null; saved: boolean }>> {
const [allBooks, progressList, savedEntries] = await Promise.all([
listBooks(),
listAll<Progress>('progress', `user_id="${userId}"`, '-updated').catch(() => [] as Progress[]),
listAll<{ id: string; slug: string; saved_at: string }>(
'user_library',
`user_id="${userId}"`,
'-saved_at'
).catch(() => [] as { id: string; slug: string; saved_at: string }[])
]);
const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));
const result: Array<{ book: Book; chapter: number | null; saved: boolean }> = [];
const seen = new Set<string>();
// Books with progress first (most recently read)
for (const p of progressList) {
const book = bookMap.get(p.slug);
if (!book || seen.has(p.slug)) continue;
seen.add(p.slug);
result.push({ book, chapter: p.chapter, saved: false });
}
// Saved-only books next
for (const e of savedEntries) {
const book = bookMap.get(e.slug);
if (!book || seen.has(e.slug)) continue;
seen.add(e.slug);
result.push({ book, chapter: null, saved: true });
}
// Mark saved flag for books that are both in progress AND saved
const savedSlugs = new Set(savedEntries.map((e) => e.slug));
return result.map((r) => ({ ...r, saved: savedSlugs.has(r.book.slug) }));
}
/**
* Returns the currently-reading books (books with progress, not completed)
* for a given user ID.
*/
export async function getUserCurrentlyReading(
userId: string
): Promise<Array<{ book: Book; chapter: number }>> {
const [allBooks, progressList] = await Promise.all([
listBooks(),
listAll<Progress>('progress', `user_id="${userId}"`, '-updated').catch(() => [] as Progress[])
]);
const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));
return progressList
.filter((p) => {
const book = bookMap.get(p.slug);
return book && p.chapter > 0 && p.chapter < book.total_chapters;
})
.slice(0, 10)
.map((p) => ({ book: bookMap.get(p.slug)!, chapter: p.chapter }));
}
/**
* Returns recently-updated books from ALL users that followerId is subscribed to.
* Deduplicates across followed users; sorts by most recently updated.
*/
export async function getSubscriptionFeed(
followerId: string,
limit = 12
): Promise<Array<{ book: Book; readerUsername: string }>> {
const followingIds = await getFollowingIds(followerId);
if (followingIds.length === 0) return [];
// Fetch all users we follow (for display names)
const token = await getToken();
const userFetches = followingIds.map((id) =>
fetch(`${PB_URL}/api/collections/app_users/records/${id}`, {
headers: { Authorization: `Bearer ${token}` }
})
.then((r) => (r.ok ? (r.json() as Promise<User>) : null))
.catch(() => null)
);
const users = (await Promise.all(userFetches)).filter(Boolean) as User[];
const userMap = new Map<string, User>(users.map((u) => [u.id, u]));
// Fetch progress for each followed user
const progressFetches = followingIds.map((id) =>
listAll<Progress>('progress', `user_id="${id}"`, '-updated').catch(() => [] as Progress[])
);
const allProgressArrays = await Promise.all(progressFetches);
const allBooks = await listBooks();
const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));
// Merge: per slug take the most-recent progress entry
const seen = new Set<string>();
const feed: Array<{ book: Book; readerUsername: string; updated: string }> = [];
for (let i = 0; i < followingIds.length; i++) {
const uid = followingIds[i];
const username = userMap.get(uid)?.username ?? 'unknown';
for (const p of allProgressArrays[i]) {
if (seen.has(p.slug)) continue;
const book = bookMap.get(p.slug);
if (!book) continue;
seen.add(p.slug);
feed.push({ book, readerUsername: username, updated: p.updated });
}
}
// Sort by most recently read across all followed users
feed.sort((a, b) => b.updated.localeCompare(a.updated));
return feed.slice(0, limit).map(({ book, readerUsername }) => ({ book, readerUsername }));
}

View File

@@ -5,6 +5,7 @@
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types';
import { audioStore } from '$lib/audio.svelte';
import { env } from '$env/dynamic/public';
let { children, data }: { children: Snippet; data: LayoutData } = $props();
@@ -395,6 +396,9 @@
<a href="/privacy" class="hover:text-zinc-500 transition-colors">Privacy</a>
<a href="/dmca" class="hover:text-zinc-500 transition-colors">DMCA</a>
<span>&copy; {new Date().getFullYear()} libnovel</span>
{#if env.PUBLIC_BUILD_VERSION && env.PUBLIC_BUILD_VERSION !== 'dev'}
<span class="text-zinc-800">{env.PUBLIC_BUILD_VERSION}+{env.PUBLIC_BUILD_COMMIT?.slice(0, 7)}</span>
{/if}
</div>
</div>
</footer>

View File

@@ -3,7 +3,8 @@ import {
listBooks,
recentlyAddedBooks,
allProgress,
getHomeStats
getHomeStats,
getSubscriptionFeed
} from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
import type { Book, Progress } from '$lib/server/pocketbase';
@@ -38,9 +39,18 @@ export const load: PageServerLoad = async ({ locals }) => {
const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug));
const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6);
// Subscription feed — only when logged in
const subscriptionFeed = locals.user
? await getSubscriptionFeed(locals.user.id, 12).catch((e) => {
log.error('home', 'failed to load subscription feed', { err: String(e) });
return [] as Awaited<ReturnType<typeof getSubscriptionFeed>>;
})
: [];
return {
continueReading,
recentlyUpdated,
subscriptionFeed,
stats: {
...stats,
booksInProgress: continueReading.length

View File

@@ -147,3 +147,56 @@
</a>
</div>
{/if}
<!-- From Subscriptions -->
{#if data.subscriptionFeed.length > 0}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-lg font-bold text-zinc-100">From People You Follow</h2>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each data.subscriptionFeed as { book, readerUsername }}
{@const genres = parseGenres(book.genres)}
<a
href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
>
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden">
{#if book.cover}
<img
src={book.cover}
alt={book.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
{/if}
</div>
<div class="p-2 flex flex-col gap-1">
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title ?? ''}</h3>
{#if book.author}
<p class="text-xs text-zinc-400 truncate">{book.author}</p>
{/if}
<!-- Reader attribution -->
<p class="text-xs text-zinc-600 truncate mt-0.5">
via <span class="text-amber-500/70">{readerUsername}</span>
</p>
{#if genres.length > 0}
<div class="flex flex-wrap gap-1 mt-auto pt-1">
{#each genres.slice(0, 1) as genre}
<span class="text-xs px-1 py-0.5 rounded bg-zinc-900 text-zinc-500">{genre}</span>
{/each}
</div>
{/if}
</div>
</a>
{/each}
</div>
</section>
{/if}

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

@@ -104,7 +104,7 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
const res = await fetch(presignUrl);
if (!res.ok) throw new Error(`MinIO returned ${res.status}`);
const markdown = await res.text();
html = await marked(markdown, { async: true });
html = marked(markdown) as string;
} catch (e) {
log.error('api/chapter', 'failed to fetch chapter content', { slug, n, err: String(e) });
}

View File

@@ -0,0 +1,26 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { deleteComment } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* DELETE /api/comment/[id]
* Deletes a comment and its replies. Only the comment owner may delete.
* Requires authentication.
*/
export const DELETE: RequestHandler = async ({ params, locals }) => {
if (!locals.user) error(401, 'Login required');
const { id } = params;
try {
await deleteComment(id, locals.user.id);
return new Response(null, { status: 204 });
} catch (e) {
const msg = String(e);
if (msg.includes('Not authorized')) error(403, 'Not authorized to delete this comment');
if (msg.includes('not found')) error(404, 'Comment not found');
log.error('api/comment/[id]', 'deleteComment failed', { id, err: msg });
error(500, 'Failed to delete comment');
}
};

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/comment/[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/comment/[id]/vote', 'voteComment failed', { id, err: String(e) });
error(500, 'Failed to record vote');
}
};

View File

@@ -0,0 +1,102 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import {
listComments,
listReplies,
createComment,
getMyVotes,
type CommentSort
} from '$lib/server/pocketbase';
import { presignAvatarUrl } from '$lib/server/minio';
import { log } from '$lib/server/logger';
/**
* GET /api/comments/[slug]?sort=new|top
* Returns top-level comments + their replies + current visitor's votes + avatar URLs.
* Response: { comments: BookComment[], myVotes: Record<string, 'up'|'down'>, avatarUrls: Record<string, string> }
* Each top-level comment has a `replies` array attached.
*/
export const GET: RequestHandler = async ({ params, url, locals }) => {
const { slug } = params;
const sortParam = url.searchParams.get('sort') ?? 'new';
const sort: CommentSort = sortParam === 'top' ? 'top' : 'new';
try {
const topLevel = await listComments(slug, sort);
// Fetch replies for all top-level comments in parallel
const repliesPerComment = await Promise.all(topLevel.map((c) => listReplies(c.id)));
const allReplies = repliesPerComment.flat();
// Build comment+reply list for vote lookup
const allIds = [...topLevel.map((c) => c.id), ...allReplies.map((r) => r.id)];
const myVotes = await getMyVotes(allIds, locals.sessionId, locals.user?.id);
// Attach replies to each top-level comment
const comments = topLevel.map((c, i) => ({
...c,
replies: repliesPerComment[i]
}));
// Batch-resolve avatar presign URLs for all unique user_ids
const allComments = [...topLevel, ...allReplies];
const uniqueUserIds = [...new Set(allComments.map((c) => c.user_id).filter(Boolean))];
const avatarEntries = await Promise.all(
uniqueUserIds.map(async (userId) => {
try {
const url = await presignAvatarUrl(userId);
return [userId, url] as [string, string | null];
} catch {
return [userId, null] as [string, null];
}
})
);
const avatarUrls: Record<string, string> = {};
for (const [userId, url] of avatarEntries) {
if (url) avatarUrls[userId] = url;
}
return json({ comments, myVotes, avatarUrls });
} 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, parent_id?: string }
* Creates a new comment or reply. 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; parent_id?: 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)');
// Enforce 1-level depth: parent_id must be a top-level comment
const parentId = body.parent_id?.trim() || undefined;
try {
const comment = await createComment(
slug,
text,
locals.user.id,
locals.user.username,
parentId
);
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

@@ -1,12 +1,19 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { listBooks, recentlyAddedBooks, allProgress, getHomeStats } from '$lib/server/pocketbase';
import {
listBooks,
recentlyAddedBooks,
allProgress,
getHomeStats,
getSubscriptionFeed
} from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
import type { Book, Progress } from '$lib/server/pocketbase';
/**
* GET /api/home
* Returns home screen data: continue-reading list, recently updated books, and stats.
* Returns home screen data: continue-reading list, recently updated books, stats,
* and subscription feed (books recently read by followed users).
* Requires authentication (enforced by layout guard).
*/
export const GET: RequestHandler = async ({ locals }) => {
@@ -36,6 +43,12 @@ export const GET: RequestHandler = async ({ locals }) => {
const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug));
const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6);
// Subscription feed — only available for logged-in users with following
let subscriptionFeed: Array<{ book: Book; readerUsername: string }> = [];
if (locals.user?.id) {
subscriptionFeed = await getSubscriptionFeed(locals.user.id).catch(() => []);
}
return json({
continue_reading: continueReading,
recently_updated: recentlyUpdated,
@@ -43,6 +56,10 @@ export const GET: RequestHandler = async ({ locals }) => {
totalBooks: stats.totalBooks,
totalChapters: stats.totalChapters,
booksInProgress: continueReading.length
}
},
subscription_feed: subscriptionFeed.map((item) => ({
book: item.book,
readerUsername: item.readerUsername
}))
});
};

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

@@ -1,6 +1,6 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { setProgress } from '$lib/server/pocketbase';
import { setProgress, deleteProgress } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
@@ -32,3 +32,23 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
return json({ ok: true });
};
/**
* DELETE /api/progress/[slug]
* Removes reading progress for a specific book (removes from library/continue reading).
*/
export const DELETE: RequestHandler = async ({ params, locals }) => {
const { slug } = params;
try {
await deleteProgress(locals.sessionId, slug, locals.user?.id);
} catch (e) {
log.error('api/progress/[slug]', 'deleteProgress failed', {
slug,
err: String(e)
});
error(500, 'Failed to delete progress');
}
return json({ ok: true });
};

View File

@@ -0,0 +1,46 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getPublicProfile, getSubscription } from '$lib/server/pocketbase';
import { presignAvatarUrl } from '$lib/server/minio';
import { log } from '$lib/server/logger';
/**
* GET /api/users/[username]
* Returns public profile info + whether the current user is subscribed.
*/
export const GET: RequestHandler = async ({ params, locals }) => {
const { username } = params;
try {
const profile = await getPublicProfile(username);
if (!profile) error(404, `User "${username}" not found`);
// Resolve avatar presigned URL if set
let avatarUrl: string | null = null;
if (profile.avatar_url) {
avatarUrl = await presignAvatarUrl(profile.id).catch(() => null);
}
// Is the current logged-in user subscribed?
let isSubscribed = false;
if (locals.user && locals.user.id !== profile.id) {
const sub = await getSubscription(locals.user.id, profile.id).catch(() => null);
isSubscribed = !!sub;
}
return json({
id: profile.id,
username: profile.username,
avatarUrl,
created: profile.created,
followerCount: profile.followerCount,
followingCount: profile.followingCount,
isSubscribed,
isSelf: locals.user?.id === profile.id
});
} catch (e) {
if ((e as { status?: number }).status === 404) throw e;
log.error('api/users', 'failed to load profile', { username, err: String(e) });
error(500, 'Failed to load profile');
}
};

View File

@@ -0,0 +1,43 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import {
getUserByUsername,
getUserPublicLibrary,
getUserCurrentlyReading
} from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* GET /api/users/[username]/library
* Returns the public library + currently-reading list for a user.
* Does not require authentication — all data is public.
*/
export const GET: RequestHandler = async ({ params }) => {
const { username } = params;
const user = await getUserByUsername(username).catch(() => null);
if (!user) error(404, `User "${username}" not found`);
try {
const [currentlyReading, library] = await Promise.all([
getUserCurrentlyReading(user.id),
getUserPublicLibrary(user.id)
]);
return json({
currently_reading: currentlyReading.map((item) => ({
book: item.book,
last_chapter: item.chapter,
saved: false
})),
library: library.map((item) => ({
book: item.book,
last_chapter: item.chapter,
saved: item.saved
}))
});
} catch (e) {
log.error('api/users/library', 'failed to load library', { username, err: String(e) });
error(500, 'Failed to load library');
}
};

View File

@@ -0,0 +1,48 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import {
getUserByUsername,
subscribe,
unsubscribe,
getSubscription
} from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* POST /api/users/[username]/subscribe — subscribe to a user
* DELETE /api/users/[username]/subscribe — unsubscribe
* Requires authentication.
*/
export const POST: RequestHandler = async ({ params, locals }) => {
if (!locals.user) error(401, 'Login required');
const { username } = params;
const target = await getUserByUsername(username).catch(() => null);
if (!target) error(404, `User "${username}" not found`);
if (locals.user.id === target.id) error(400, 'Cannot subscribe to yourself');
try {
await subscribe(locals.user.id, target.id);
const sub = await getSubscription(locals.user.id, target.id);
return json({ subscribed: true, subId: sub?.id ?? null });
} catch (e) {
log.error('api/users/subscribe', 'subscribe failed', { username, err: String(e) });
error(500, 'Failed to subscribe');
}
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
if (!locals.user) error(401, 'Login required');
const { username } = params;
const target = await getUserByUsername(username).catch(() => null);
if (!target) error(404, `User "${username}" not found`);
try {
await unsubscribe(locals.user.id, target.id);
return json({ subscribed: false });
} catch (e) {
log.error('api/users/subscribe', 'unsubscribe failed', { username, err: String(e) });
error(500, 'Failed to unsubscribe');
}
};

View File

@@ -43,7 +43,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
inLib: true,
saved,
lastChapter: progress?.chapter ?? null,
isAdmin: locals.user?.role === 'admin'
isAdmin: locals.user?.role === 'admin',
isLoggedIn: !!locals.user,
currentUserId: locals.user?.id ?? ''
};
}
@@ -93,7 +95,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
inLib: preview.in_lib,
saved: false,
lastChapter: null,
isAdmin: locals.user?.role === 'admin'
isAdmin: locals.user?.role === 'admin',
isLoggedIn: !!locals.user,
currentUserId: locals.user?.id ?? ''
};
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;

View File

@@ -1,7 +1,6 @@
<script lang="ts">
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();
@@ -32,57 +31,12 @@
const genres = $derived(parseGenres(data.book.genres));
// Paginate chapter list — 50 on mobile, 100 on sm+ (≥640px)
let pageSize = $state(50);
onMount(() => {
const mq = window.matchMedia('(min-width: 640px)');
pageSize = mq.matches ? 100 : 50;
const handler = (e: MediaQueryListEvent) => { pageSize = e.matches ? 100 : 50; };
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
});
// Start on the page that contains the current chapter (if any)
function pageForChapter(chapterNum: number | null, list: typeof chapterList): number {
if (!chapterNum || list.length === 0) return 0;
const idx = list.findIndex((c) => c.number === chapterNum);
if (idx === -1) return 0;
return Math.floor(idx / pageSize);
}
let page = $state(pageForChapter(data.lastChapter, data.inLib ? data.chapters : (data.previewChapters ?? [])));
// Use preview chapters if the book is not in the library
// Use preview chapters if the book is not in the library (needed for chapter count)
const chapterList = $derived(
data.inLib
? data.chapters
: (data.previewChapters ?? [])
);
const totalPages = $derived(Math.ceil(chapterList.length / pageSize));
const visibleChapters = $derived(
chapterList.slice(page * pageSize, (page + 1) * pageSize)
);
// ── Chapter list polling ──────────────────────────────────────────────────
// When the book was just added to the library via preview (inLib=true but
// no chapters yet), poll until the background WriteChapterRefs completes.
let pollingChapters = $state(data.inLib && data.chapters.length === 0);
onMount(() => {
if (!pollingChapters) return;
let attempts = 0;
const MAX_ATTEMPTS = 20; // ~10 seconds
const timer = setInterval(async () => {
attempts++;
await invalidateAll();
if (data.chapters.length > 0 || attempts >= MAX_ATTEMPTS) {
pollingChapters = false;
clearInterval(timer);
}
}, 500);
return () => clearInterval(timer);
});
// ── Admin: rescrape ───────────────────────────────────────────────────────
let scraping = $state(false);
@@ -137,26 +91,6 @@
}
}
async function scrapeFromChapter(n: number) {
if (rangeScraping || !data.book.source_url) return;
rangeScraping = true;
rangeResult = '';
try {
const res = await fetch('/api/scrape/range', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: data.book.source_url, from: n })
});
if (res.ok) rangeResult = 'queued';
else if (res.status === 409) rangeResult = 'busy';
else rangeResult = 'error';
} catch {
rangeResult = 'error';
} finally {
rangeScraping = false;
}
}
// ── Summary expand/collapse ───────────────────────────────────────────────
let summaryExpanded = $state(false);
@@ -341,101 +275,36 @@
</div>
</div>
<!-- ══════════════════════════════════════════════════ Chapter list ══ -->
<div>
<!-- Header row: title + pagination -->
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
<h2 class="text-base font-semibold text-zinc-200">
Chapters
<!-- ══════════════════════════════════════════════════ Chapters row ══ -->
<div class="flex flex-col divide-y divide-zinc-800 border border-zinc-800 rounded-xl overflow-hidden mb-6">
<!-- Chapters row: links to the full chapter list page -->
<a
href="/books/{data.book.slug}/chapters"
class="flex items-center gap-3 px-4 py-3.5 hover:bg-zinc-800/60 transition-colors group"
>
<svg class="w-4 h-4 text-amber-400 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h10"/>
</svg>
<div class="flex flex-col min-w-0 flex-1">
<span class="text-sm font-semibold text-zinc-200">Chapters</span>
{#if chapterList.length > 0}
<span class="text-zinc-500 font-normal text-sm ml-1">({chapterList.length})</span>
{/if}
</h2>
{#if totalPages > 1}
<div class="flex gap-2 items-center text-sm">
<button
onclick={() => (page = Math.max(0, page - 1))}
disabled={page === 0}
class="px-2 py-1 rounded bg-zinc-800 text-zinc-300 disabled:opacity-40 hover:bg-zinc-700 transition-colors"
>
&larr;
</button>
<span class="text-zinc-500 text-xs tabular-nums">
{page * pageSize + 1}{Math.min((page + 1) * pageSize, chapterList.length)} of {chapterList.length}
</span>
<button
onclick={() => (page = Math.min(totalPages - 1, page + 1))}
disabled={page === totalPages - 1}
class="px-2 py-1 rounded bg-zinc-800 text-zinc-300 disabled:opacity-40 hover:bg-zinc-700 transition-colors"
>
&rarr;
</button>
</div>
{/if}
</div>
<!-- Chapter rows -->
{#if pollingChapters}
<div class="flex items-center gap-3 py-4 text-zinc-500 text-sm">
<svg class="w-4 h-4 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Indexing chapter list…
</div>
<div class="flex flex-col gap-0.5 opacity-40 pointer-events-none">
{#each Array(8) as _}
<div class="h-9 rounded bg-zinc-800 animate-pulse"></div>
{/each}
</div>
{:else if chapterList.length === 0}
<p class="text-zinc-500 text-sm">No chapters available yet.</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each visibleChapters as chapter}
{@const isCurrent = data.lastChapter === chapter.number}
{@const chapterUrl = data.inLib
? `/books/${data.book.slug}/chapters/${chapter.number}`
: `/books/${data.book.slug}/chapters/${chapter.number}?preview=1&chapter_url=${encodeURIComponent((chapter as { url?: string }).url ?? '')}&title=${encodeURIComponent(chapter.title ?? '')}`}
<div class="flex items-center gap-2 px-3 py-2.5 rounded hover:bg-zinc-800/70 transition-colors group {isCurrent ? 'bg-zinc-800' : ''}">
<a href={chapterUrl} class="flex items-center gap-2 flex-1 min-w-0">
<!-- Chapter number -->
<span class="text-sm font-mono w-10 text-right flex-shrink-0 {isCurrent ? 'text-amber-400' : 'text-zinc-600'}">
{chapter.number}
</span>
<!-- Title -->
<span class="text-base {isCurrent ? 'text-amber-300' : 'text-zinc-300 group-hover:text-zinc-100'} truncate min-w-0 flex-1 transition-colors">
{chapter.title || `Chapter ${chapter.number}`}
</span>
<!-- Date label — desktop only -->
{#if (chapter as { date_label?: string }).date_label}
<span class="text-sm text-zinc-600 flex-shrink-0 max-sm:hidden">&middot; {(chapter as { date_label?: string }).date_label}</span>
{/if}
<!-- "reading" badge -->
{#if isCurrent}
<span class="text-sm text-amber-500 flex-shrink-0 font-medium">reading</span>
{/if}
</a>
<!-- Admin: scrape from this chapter up (hover-only) -->
{#if data.isAdmin && data.book.source_url && data.inLib}
<button
onclick={() => scrapeFromChapter(chapter.number)}
disabled={rangeScraping}
class="opacity-0 group-hover:opacity-100 shrink-0 text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-500 hover:bg-amber-500/30 transition-all border border-amber-500/20 disabled:opacity-30"
title="Scrape from chapter {chapter.number} up"
>
↑ here
</button>
<span class="text-xs text-zinc-500">
{#if data.lastChapter && data.lastChapter > 0}
Reading ch.{data.lastChapter} of {chapterList.length}
{:else}
{chapterList.length} chapter{chapterList.length === 1 ? '' : 's'}
{/if}
</div>
{/each}
</span>
{/if}
</div>
{/if}
<svg class="w-4 h-4 text-zinc-600 group-hover:text-zinc-400 transition-colors flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</a>
<!-- ── Admin panel (collapsed by default) ── -->
<!-- Admin panel (collapsed by default, admin only) -->
{#if data.isAdmin && data.book.source_url}
<div class="mt-6 border border-zinc-800 rounded-lg overflow-hidden">
<div>
<button
onclick={() => (adminOpen = !adminOpen)}
class="w-full flex items-center gap-2 px-4 py-2.5 text-xs font-medium text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/50 transition-colors text-left"
@@ -525,3 +394,6 @@
</div>
{/if}
</div>
<!-- ══════════════════════════════════════════════════ Comments ══ -->
<CommentsSection slug={data.book.slug} isLoggedIn={data.isLoggedIn} currentUserId={data.currentUserId} />

View File

@@ -0,0 +1,32 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { getBook, listChapterIdx, getProgress } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
export const load: PageServerLoad = async ({ params, locals }) => {
const { slug } = params;
const book = await getBook(slug).catch((e) => {
log.error('chapters', 'getBook failed', { slug, err: String(e) });
return null;
});
if (!book) error(404, `Book "${slug}" not found`);
let chapters, progress;
try {
[chapters, progress] = await Promise.all([
listChapterIdx(slug),
getProgress(locals.sessionId, slug, locals.user?.id)
]);
} catch (e) {
log.error('chapters', 'failed to load chapters', { slug, err: String(e) });
throw error(500, 'Failed to load chapters');
}
return {
book: { slug: book.slug, title: book.title, cover: book.cover ?? '', totalChapters: book.total_chapters },
chapters,
lastChapter: progress?.chapter ?? null
};
};

View File

@@ -0,0 +1,203 @@
<script lang="ts">
import type { PageData } from './$types';
import type { ChapterIdx } from '$lib/server/pocketbase';
let { data }: { data: PageData } = $props();
const PAGE_SIZE = 100;
// ── Search ──────────────────────────────────────────────────────────────────
let searchQuery = $state('');
const filtered = $derived(
(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return data.chapters;
return data.chapters.filter(
(c: ChapterIdx) =>
String(c.number).includes(q) ||
c.title.toLowerCase().includes(q)
);
})()
);
// ── Page groups (only shown when not searching) ──────────────────────────
const totalGroups = $derived(Math.ceil(data.chapters.length / PAGE_SIZE));
// Which group the current chapter is in (0-indexed)
const currentGroup = $derived(
data.lastChapter
? Math.floor(
(data.chapters.findIndex((c: ChapterIdx) => c.number === data.lastChapter)) /
PAGE_SIZE
)
: 0
);
let activeGroup = $state(0);
// On mount, jump to the group containing the current chapter
$effect(() => {
if (data.lastChapter && currentGroup >= 0) {
activeGroup = currentGroup;
}
});
const visibleChapters = $derived(
searchQuery.trim()
? filtered
: data.chapters.slice(activeGroup * PAGE_SIZE, (activeGroup + 1) * PAGE_SIZE)
);
function groupLabel(i: number): string {
const from = i * PAGE_SIZE + 1;
const to = Math.min((i + 1) * PAGE_SIZE, data.chapters.length);
return `${from}${to}`;
}
</script>
<svelte:head>
<title>{data.book.title} — Chapters — libnovel</title>
</svelte:head>
<!-- ── Back link + title ─────────────────────────────────────────────────── -->
<div class="flex items-center gap-3 mb-5">
<a
href="/books/{data.book.slug}"
class="flex items-center gap-1.5 text-zinc-400 hover:text-zinc-200 transition-colors text-sm"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
</svg>
Back
</a>
<span class="text-zinc-700">/</span>
<h1 class="text-base font-semibold text-zinc-200 truncate">{data.book.title}</h1>
</div>
<!-- ── Search bar ───────────────────────────────────────────────────────── -->
<div class="relative mb-4">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35"/>
</svg>
<input
type="search"
placeholder="Search chapters…"
bind:value={searchQuery}
class="w-full pl-9 pr-4 py-2.5 rounded-lg bg-zinc-800 border border-zinc-700 text-zinc-200 placeholder-zinc-500 text-sm focus:outline-none focus:border-amber-400 transition-colors"
/>
{#if searchQuery}
<button
onclick={() => (searchQuery = '')}
class="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
aria-label="Clear search"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
{/if}
</div>
<!-- ── Page-group selector (hidden while searching) ──────────────────────── -->
{#if !searchQuery && totalGroups > 1}
<div class="flex flex-wrap gap-1.5 mb-4">
{#each Array(totalGroups) as _, i}
<button
onclick={() => (activeGroup = i)}
class="px-2.5 py-1 rounded text-xs font-medium transition-colors
{activeGroup === i
? 'bg-amber-400 text-zinc-900'
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200'}
{currentGroup === i && activeGroup !== i ? 'ring-1 ring-amber-400/50' : ''}"
>
{groupLabel(i)}
</button>
{/each}
</div>
{/if}
<!-- ── Jump-to-current banner ──────────────────────────────────────────── -->
{#if data.lastChapter && data.lastChapter > 0 && !searchQuery && activeGroup !== currentGroup}
<button
onclick={() => (activeGroup = currentGroup)}
class="flex items-center gap-2 w-full px-3 py-2 mb-3 rounded-lg bg-amber-400/10 border border-amber-400/25 text-amber-400 text-sm hover:bg-amber-400/20 transition-colors"
>
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
</svg>
Jump to Ch.{data.lastChapter}
</button>
{/if}
<!-- ── Chapter list ───────────────────────────────────────────────────── -->
{#if visibleChapters.length === 0}
{#if searchQuery}
<p class="text-zinc-500 text-sm py-8 text-center">No chapters match "{searchQuery}"</p>
{:else}
<p class="text-zinc-500 text-sm">No chapters available yet.</p>
{/if}
{:else}
<!-- Result count while searching -->
{#if searchQuery}
<p class="text-xs text-zinc-500 mb-2">{visibleChapters.length} result{visibleChapters.length === 1 ? '' : 's'}</p>
{/if}
<div class="flex flex-col gap-0.5">
{#each visibleChapters as chapter}
{@const isCurrent = data.lastChapter === chapter.number}
<a
href="/books/{data.book.slug}/chapters/{chapter.number}"
id="ch-{chapter.number}"
class="flex items-center gap-3 px-3 py-2.5 rounded transition-colors group
{isCurrent ? 'bg-zinc-800' : 'hover:bg-zinc-800/60'}"
>
<!-- Number badge -->
<span
class="w-9 text-right text-sm font-mono flex-shrink-0
{isCurrent ? 'text-amber-400 font-semibold' : 'text-zinc-600'}"
>
{chapter.number}
</span>
<!-- Title -->
<span
class="flex-1 min-w-0 text-sm truncate transition-colors
{isCurrent ? 'text-amber-300 font-medium' : 'text-zinc-300 group-hover:text-zinc-100'}"
>
{chapter.title || `Chapter ${chapter.number}`}
</span>
<!-- Date — desktop only -->
{#if chapter.date_label}
<span class="hidden sm:block text-xs text-zinc-600 flex-shrink-0">
{chapter.date_label}
</span>
{/if}
<!-- Reading indicator -->
{#if isCurrent}
<span class="text-xs text-amber-500 font-medium flex-shrink-0">reading</span>
{/if}
</a>
{/each}
</div>
<!-- Bottom page-group nav (mirrors top, for long lists) -->
{#if !searchQuery && totalGroups > 1}
<div class="flex flex-wrap gap-1.5 mt-5 pt-4 border-t border-zinc-800">
{#each Array(totalGroups) as _, i}
<button
onclick={() => { activeGroup = i; window.scrollTo({ top: 0, behavior: 'smooth' }); }}
class="px-2.5 py-1 rounded text-xs font-medium transition-colors
{activeGroup === i
? 'bg-amber-400 text-zinc-900'
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200'}
{currentGroup === i && activeGroup !== i ? 'ring-1 ring-amber-400/50' : ''}"
>
{groupLabel(i)}
</button>
{/each}
</div>
{/if}
{/if}

View File

@@ -114,7 +114,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
const res = await fetch(presignUrl);
if (!res.ok) throw new Error(`MinIO returned ${res.status}`);
const markdown = await res.text();
html = await marked(markdown, { async: true });
html = marked(markdown) as string;
} catch (e) {
// Don't hard-fail — show empty content with error message
log.error('chapter', 'failed to fetch chapter content', { slug, n, err: String(e) });

View File

@@ -2,10 +2,13 @@
import { onMount } from 'svelte';
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
import type { PageData } from './$types';
import { marked } from 'marked';
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 +18,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) {
@@ -42,6 +41,7 @@
if (!res.ok) throw new Error(`status ${res.status}`);
const d = (await res.json()) as { text?: string };
if (d.text) {
const { marked } = await import('marked');
html = await marked(d.text, { async: true });
} else {
fetchError = 'Chapter content not available.';

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

View File

@@ -0,0 +1,59 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import {
getPublicProfile,
getSubscription,
getUserPublicLibrary,
getUserCurrentlyReading
} from '$lib/server/pocketbase';
import { presignAvatarUrl } from '$lib/server/minio';
import { log } from '$lib/server/logger';
export const load: PageServerLoad = async ({ params, locals }) => {
const { username } = params;
const profile = await getPublicProfile(username).catch(() => null);
if (!profile) error(404, `User "${username}" not found`);
// Resolve avatar
let avatarUrl: string | null = null;
if (profile.avatar_url) {
avatarUrl = await presignAvatarUrl(profile.id).catch(() => null);
}
// Subscription state for the logged-in visitor
let isSubscribed = false;
const isSelf = locals.user?.id === profile.id;
if (locals.user && !isSelf) {
const sub = await getSubscription(locals.user.id, profile.id).catch(() => null);
isSubscribed = !!sub;
}
// Load public library + currently reading in parallel
const [library, currentlyReading] = await Promise.all([
getUserPublicLibrary(profile.id).catch((e) => {
log.error('users/profile', 'getUserPublicLibrary failed', { username, err: String(e) });
return [] as Awaited<ReturnType<typeof getUserPublicLibrary>>;
}),
getUserCurrentlyReading(profile.id).catch((e) => {
log.error('users/profile', 'getUserCurrentlyReading failed', { username, err: String(e) });
return [] as Awaited<ReturnType<typeof getUserCurrentlyReading>>;
})
]);
return {
profile: {
id: profile.id,
username: profile.username,
created: profile.created,
followerCount: profile.followerCount,
followingCount: profile.followingCount
},
avatarUrl,
isSubscribed,
isSelf,
isLoggedIn: !!locals.user,
library,
currentlyReading
};
};

View File

@@ -0,0 +1,224 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
// ── Subscribe / unsubscribe ──────────────────────────────────────────────────
let subscribed = $state(data.isSubscribed);
let followerCount = $state(data.profile.followerCount);
let subLoading = $state(false);
async function toggleSubscribe() {
if (subLoading) return;
subLoading = true;
try {
const method = subscribed ? 'DELETE' : 'POST';
const res = await fetch(`/api/users/${data.profile.username}/subscribe`, { method });
if (res.ok) {
subscribed = !subscribed;
followerCount += subscribed ? 1 : -1;
}
} finally {
subLoading = false;
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function initials(username: string): string {
return username.slice(0, 2).toUpperCase();
}
function joinDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'long' });
} catch {
return '';
}
}
function parseGenres(genres: string[] | string | null | undefined): string[] {
if (!genres) return [];
if (Array.isArray(genres)) return genres;
try {
const parsed = JSON.parse(genres);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
</script>
<svelte:head>
<title>{data.profile.username} — libnovel</title>
</svelte:head>
<!-- ── Header ────────────────────────────────────────────────────────────── -->
<div class="flex items-start gap-5 mb-8">
<!-- Avatar -->
<div class="flex-shrink-0">
{#if data.avatarUrl}
<img
src={data.avatarUrl}
alt={data.profile.username}
class="w-20 h-20 rounded-full object-cover ring-2 ring-zinc-700"
/>
{:else}
<div class="w-20 h-20 rounded-full bg-zinc-700 flex items-center justify-center text-2xl font-bold text-zinc-300 ring-2 ring-zinc-600">
{initials(data.profile.username)}
</div>
{/if}
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<h1 class="text-xl font-bold text-zinc-100 mb-0.5">{data.profile.username}</h1>
<p class="text-xs text-zinc-500 mb-3">Joined {joinDate(data.profile.created)}</p>
<!-- Stats row -->
<div class="flex gap-5 text-sm mb-4">
<span>
<span class="font-semibold text-zinc-100">{followerCount}</span>
<span class="text-zinc-500 ml-1">followers</span>
</span>
<span>
<span class="font-semibold text-zinc-100">{data.profile.followingCount}</span>
<span class="text-zinc-500 ml-1">following</span>
</span>
</div>
<!-- Subscribe button — only shown to logged-in visitors viewing someone else's profile -->
{#if data.isLoggedIn && !data.isSelf}
<button
onclick={toggleSubscribe}
disabled={subLoading}
class="px-4 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50
{subscribed
? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600 border border-zinc-600'
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300'}"
>
{#if subLoading}
{:else if subscribed}
Following
{:else}
Follow
{/if}
</button>
{:else if !data.isLoggedIn}
<a
href="/login"
class="inline-block px-4 py-1.5 rounded-lg text-sm font-medium bg-amber-400 text-zinc-900 hover:bg-amber-300 transition-colors"
>
Follow
</a>
{/if}
</div>
</div>
<!-- ── Currently Reading ─────────────────────────────────────────────────── -->
{#if data.currentlyReading.length > 0}
<section class="mb-10">
<h2 class="text-base font-semibold text-zinc-200 mb-3">Currently Reading</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each data.currentlyReading as { book, chapter }}
<a
href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
>
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden relative">
{#if book.cover}
<img
src={book.cover}
alt={book.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
{/if}
<span class="absolute bottom-1.5 right-1.5 text-xs bg-amber-400 text-zinc-900 font-bold px-1.5 py-0.5 rounded">
ch.{chapter}
</span>
</div>
<div class="p-2">
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title}</h3>
{#if book.author}
<p class="text-xs text-zinc-500 truncate mt-0.5">{book.author}</p>
{/if}
</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- ── Library ───────────────────────────────────────────────────────────── -->
{#if data.library.length > 0}
<section class="mb-10">
<h2 class="text-base font-semibold text-zinc-200 mb-3">
Library
<span class="text-zinc-500 font-normal text-sm ml-1">({data.library.length})</span>
</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each data.library as { book, chapter, saved }}
{@const genres = parseGenres(book.genres)}
<a
href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
>
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden relative">
{#if book.cover}
<img
src={book.cover}
alt={book.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
{/if}
{#if chapter}
<span class="absolute bottom-1.5 right-1.5 text-xs bg-zinc-900/80 text-zinc-300 font-medium px-1.5 py-0.5 rounded">
ch.{chapter}
</span>
{/if}
{#if saved && !chapter}
<span class="absolute top-1.5 right-1.5">
<svg class="w-3.5 h-3.5 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 3a2 2 0 00-2 2v16l9-4 9 4V5a2 2 0 00-2-2H5z"/>
</svg>
</span>
{/if}
</div>
<div class="p-2">
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title}</h3>
{#if book.author}
<p class="text-xs text-zinc-500 truncate mt-0.5">{book.author}</p>
{/if}
{#if genres.length > 0}
<p class="text-xs text-zinc-600 truncate mt-0.5">{genres[0]}</p>
{/if}
</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- ── Empty state ───────────────────────────────────────────────────────── -->
{#if data.library.length === 0 && data.currentlyReading.length === 0}
<div class="py-16 text-center text-zinc-500">
<svg class="w-10 h-10 mx-auto mb-3 text-zinc-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<p class="text-sm">No books in library yet.</p>
</div>
{/if}

View File

@@ -3,5 +3,11 @@ import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
plugins: [tailwindcss(), sveltekit()],
ssr: {
// Force these packages to be bundled into the server output rather than
// treated as external requires. The production Docker image has no
// node_modules, so anything used in server-side code must be inlined.
noExternal: ['marked']
}
});