Compare commits

..

32 Commits

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

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

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

Successfully tested locally - archive builds and signs correctly.
2026-03-09 19:14:01 +05:00
Admin
dc3bc3ebf2 Use provisioning profile UUID instead of name for manual signing
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 14s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Test (pull_request) Successful in 17s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 1m31s
iOS CI / Test (pull_request) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 57s
iOS CI / Test (push) Successful in 4m29s
2026-03-09 19:02:17 +05:00
Admin
e9d7293d37 Switch to manual code signing for CI (automatic signing requires Apple ID)
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 15s
CI / Scraper / Test (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 16s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 57s
2026-03-09 18:57:21 +05:00
Admin
410af8f236 Fix iOS build: use update_code_signing_settings and explicit -allowProvisioningUpdates flags
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 13s
CI / UI / Build (pull_request) Successful in 15s
CI / Scraper / Test (pull_request) Successful in 18s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 58s
2026-03-09 18:52:04 +05:00
Admin
264c00c765 fix: switch to automatic signing for archive, manual for export
Some checks failed
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
CI / Scraper / Test (pull_request) Successful in 15s
CI / Scraper / Lint (pull_request) Successful in 16s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS Release / Release to TestFlight (push) Failing after 1m3s
2026-03-09 18:37:15 +05:00
Admin
e4c72011eb fix: extract and use actual profile name from mobileprovision file
Some checks failed
CI / Scraper / Test (pull_request) Successful in 13s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Lint (pull_request) Successful in 18s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 1m3s
2026-03-09 18:27:29 +05:00
Admin
6365b14ece fix: use update_code_signing_settings to configure provisioning profile
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 13s
CI / UI / Build (pull_request) Successful in 17s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 1m1s
2026-03-09 18:23:44 +05:00
Admin
7da5582075 fix: add manual code signing args to build_app in fastlane
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 27s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 54s
2026-03-09 18:18:30 +05:00
Admin
dae841e317 fix: set USER and locale environment variables for xcodegen
Some checks failed
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
CI / Scraper / Test (pull_request) Successful in 16s
CI / UI / Build (pull_request) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 19s
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS Release / Release to TestFlight (push) Failing after 43s
2026-03-09 18:16:25 +05:00
Admin
16b2bfffa6 fix: use correct path for xcodegen in fastlane
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 11s
CI / Scraper / Test (pull_request) Successful in 18s
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
CI / UI / Build (pull_request) Successful in 28s
CI / UI / Docker Push (pull_request) Has been skipped
iOS Release / Release to TestFlight (push) Failing after 25s
2026-03-09 18:14:33 +05:00
Admin
57be674f44 feat: use rbenv to install Ruby 3.2.2 for fastlane
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 17s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 21s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 1m51s
2026-03-09 16:12:02 +05:00
Admin
93390fab64 fix: install fastlane via Homebrew instead of bundler
Some checks failed
CI / Scraper / Test (pull_request) Successful in 8s
CI / Scraper / Lint (pull_request) Successful in 19s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 23s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 11s
iOS CI / Test (pull_request) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
2026-03-09 16:11:16 +05:00
Admin
072517135f fix: use system Ruby instead of ruby/setup-ruby action
Some checks failed
CI / Scraper / Test (pull_request) Successful in 14s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
CI / Scraper / Lint (pull_request) Successful in 17s
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
CI / UI / Build (pull_request) Successful in 22s
CI / UI / Docker Push (pull_request) Has been skipped
iOS Release / Release to TestFlight (push) Failing after 1m27s
2026-03-09 16:08:25 +05:00
Admin
fe7c7acbb7 feat: migrate iOS release to fastlane for simplified code signing
Some checks failed
CI / Scraper / Test (pull_request) Successful in 9s
CI / Scraper / Lint (pull_request) Successful in 12s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 26s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 3m58s
2026-03-09 15:28:29 +05:00
Admin
d4cce915d9 fix: explicitly set PROVISIONING_PROFILE with sdk filter for iphoneos only
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Test (pull_request) Successful in 18s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 1m37s
iOS Release / Release to TestFlight (push) Failing after 26s
iOS CI / Build (pull_request) Successful in 1m30s
iOS CI / Test (push) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
2026-03-09 15:23:26 +05:00
Admin
ac24e86f7d fix: use automatic provisioning with -allowProvisioningUpdates
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 14s
CI / UI / Build (pull_request) Successful in 17s
CI / Scraper / Test (pull_request) Successful in 18s
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 2m4s
iOS Release / Release to TestFlight (push) Failing after 32s
iOS CI / Build (pull_request) Successful in 1m39s
iOS CI / Test (push) Successful in 4m31s
iOS CI / Test (pull_request) Successful in 7m44s
2026-03-09 14:37:40 +05:00
Admin
e9bb387f71 fix: use PROVISIONING_PROFILE with UUID and -allowProvisioningUpdates flag
Some checks failed
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 14s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 26s
CI / UI / Docker Push (pull_request) Has been skipped
iOS Release / Release to TestFlight (push) Failing after 35s
2026-03-09 14:35:00 +05:00
Admin
d7319b3f7c fix: use PROVISIONING_PROFILE_SPECIFIER with profile name for manual signing
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 7s
CI / Scraper / Test (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
CI / UI / Build (pull_request) Successful in 22s
CI / UI / Docker Push (pull_request) Has been skipped
iOS Release / Release to TestFlight (push) Failing after 59s
2026-03-09 14:32:10 +05:00
Admin
f380c85815 ci: add p12 import diagnostic output
Some checks failed
CI / Scraper / Test (pull_request) Successful in 9s
CI / Scraper / Lint (pull_request) Successful in 12s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 25s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 2m10s
iOS Release / Release to TestFlight (push) Failing after 35s
iOS CI / Build (pull_request) Successful in 1m34s
iOS CI / Test (push) Successful in 4m29s
iOS CI / Test (pull_request) Has been cancelled
2026-03-09 14:20:28 +05:00
Admin
9d1b340b83 ci: debug keychain identities and preserve existing keychains in search list
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 12s
CI / Scraper / Test (pull_request) Successful in 14s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 15s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 28s
2026-03-09 14:03:15 +05:00
Admin
a307ddc9f5 ci: fix CODE_SIGN_IDENTITY to 'Apple Distribution' to match modern certificate name
Some checks failed
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
CI / Scraper / Lint (pull_request) Successful in 13s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Test (pull_request) Successful in 18s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS Release / Release to TestFlight (push) Failing after 34s
iOS CI / Build (pull_request) Successful in 2m23s
iOS CI / Test (pull_request) Successful in 4m17s
2026-03-09 14:01:40 +05:00
Admin
004d1b6d9d fix: correct bundle ID to com.kalekber.LibNovel to match provisioning profile
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 17s
CI / Scraper / Test (pull_request) Successful in 19s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 28s
2026-03-09 13:57:33 +05:00
48 changed files with 5271 additions and 1094 deletions

View File

@@ -1,138 +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":{...}}}}]
concurrency:
group: ${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
# ── production deploy (main/master only) ─────────────────────────────────────
deploy-production:
name: Deploy Production
runs-on: ubuntu-latest
if: >
gitea.event_name == 'push' &&
(gitea.ref == 'refs/heads/main' || gitea.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: >
gitea.event_name == 'push' &&
gitea.ref != 'refs/heads/main' &&
gitea.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 "${{ gitea.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: gitea.event_name == 'pull_request' && gitea.event.action == 'closed'
steps:
- name: Sanitize branch name
id: branch
run: |
SUFFIX=$(echo "${{ gitea.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

@@ -1,118 +0,0 @@
name: iOS Release
on:
push:
tags:
- "ios-v*"
concurrency:
group: ios-macos-runner
cancel-in-progress: false
jobs:
# ── archive & release to TestFlight ──────────────────────────────────────
# Triggered only on ios-v* tags (e.g. ios-v1.0.0).
# Required secrets:
# APPLE_CERTIFICATE_BASE64 - Distribution certificate (.p12) base64-encoded
# APPLE_CERTIFICATE_PASSWORD - Password for the .p12 file
# APPLE_PROVISIONING_PROFILE_BASE64 - App Store distribution profile base64-encoded
# KEYCHAIN_PASSWORD - Temporary keychain password (any random string)
# ASC_KEY_ID - App Store Connect API key ID
# ASC_ISSUER_ID - App Store Connect issuer ID
# ASC_PRIVATE_KEY - Contents of the .p8 private key file
# APPLE_TEAM_ID - 10-character Apple Developer team ID (GHZXC6FVMU)
release:
name: Release to TestFlight
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Install just
run: command -v just || brew install just
- name: Set build number from run number
run: just ios-set-build-number ${{ gitea.run_number }}
- 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
UUID=$(security cms -D -i "$PP_PATH" | plutil -extract UUID raw -)
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
- name: Write App Store Connect API key
env:
ASC_PRIVATE_KEY: ${{ secrets.ASC_PRIVATE_KEY }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
run: |
mkdir -p ~/private_keys
echo "$ASC_PRIVATE_KEY" > ~/private_keys/AuthKey_$ASC_KEY_ID.p8
- name: Inject team ID into ExportOptions.plist
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
/usr/libexec/PlistBuddy -c \
"Set :teamID $APPLE_TEAM_ID" \
ios/LibNovel/ExportOptions.plist
- name: Archive
env:
USER: runner
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
PROFILE_UUID=$(security cms -D -i $RUNNER_TEMP/profile.mobileprovision | plutil -extract UUID raw -)
PROFILE_NAME=$(security cms -D -i $RUNNER_TEMP/profile.mobileprovision | plutil -extract Name raw -)
PROFILE_BUNDLE=$(security cms -D -i $RUNNER_TEMP/profile.mobileprovision | plutil -extract Entitlements.application-identifier raw - 2>/dev/null || echo "n/a")
PROFILE_TEAM=$(security cms -D -i $RUNNER_TEMP/profile.mobileprovision | plutil -extract TeamIdentifier.0 raw -)
PROFILE_EXPIRY=$(security cms -D -i $RUNNER_TEMP/profile.mobileprovision | plutil -extract ExpirationDate raw -)
echo "DEBUG: PROFILE_UUID=$PROFILE_UUID"
echo "DEBUG: PROFILE_NAME=$PROFILE_NAME"
echo "DEBUG: PROFILE_BUNDLE=$PROFILE_BUNDLE"
echo "DEBUG: PROFILE_TEAM=$PROFILE_TEAM"
echo "DEBUG: PROFILE_EXPIRY=$PROFILE_EXPIRY"
echo "DEBUG: APPLE_TEAM_ID=$APPLE_TEAM_ID"
echo "DEBUG: profiles dir listing:"
ls ~/Library/MobileDevice/Provisioning\ Profiles/
just ios-archive "$APPLE_TEAM_ID" "$PROFILE_UUID"
- name: Export IPA
run: just ios-export
- name: Upload to TestFlight
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
run: just ios-upload
- name: Upload IPA artifact
uses: actions/upload-artifact@v4
with:
name: LibNovel-${{ gitea.ref_name }}.ipa
path: ${{ env.RUNNER_TEMP }}/ipa/LibNovel.ipa
retention-days: 30
- name: Cleanup keychain
if: always()
run: security delete-keychain $RUNNER_TEMP/app-signing.keychain-db

View File

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

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

@@ -3,9 +3,9 @@
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<string>app-store</string>
<key>teamID</key>
<string>$(DEVELOPMENT_TEAM)</string>
<string>GHZXC6FVMU</string>
<key>uploadBitcode</key>
<false/>
<key>uploadSymbols</key>
@@ -14,7 +14,7 @@
<string>manual</string>
<key>provisioningProfiles</key>
<dict>
<key>cc.kalekber.libnovel</key>
<key>com.kalekber.LibNovel</key>
<string>LibNovel Distribution</string>
</dict>
</dict>

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 */; };
@@ -29,7 +30,6 @@
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 09584EAB68A07B47F876A062 /* Kingfisher */; };
E1F564399D1325F6A1B2B84F /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C21107BECA55C07416E0CB8B /* LibraryView.swift */; };
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D83BB88C4306BE7A4F947CB /* Color+App.swift */; };
A1B2C3D4E5F6789012345678 /* String+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F67890123456789A /* String+App.swift */; };
EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6268D60803940CBD38FB921 /* HomeView.swift */; };
F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E89FD8F46747CA653C5203D /* CommonViews.swift */; };
F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CAFB96D2500F34F0B0C860C /* NavDestination.swift */; };
@@ -50,7 +50,7 @@
/* Begin PBXFileReference section */
1B8BF3DB582A658386E402C7 /* LibNovel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LibNovel.app; sourceTree = BUILT_PRODUCTS_DIR; };
1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = "<group>"; };
235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LibNovelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LibNovelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
2D5C115992F1CE2326236765 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = "<group>"; };
39DE056C37FBC5EED8771821 /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = "<group>"; };
3AB2E843D93461074A89A171 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
@@ -67,7 +67,6 @@
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = "<group>"; };
9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewModel.swift; sourceTree = "<group>"; };
9D83BB88C4306BE7A4F947CB /* Color+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+App.swift"; sourceTree = "<group>"; };
B2C3D4E5F67890123456789A /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+App.swift"; sourceTree = "<group>"; };
B4C918833E173D6B44D06955 /* LibNovelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelTests.swift; sourceTree = "<group>"; };
B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
@@ -77,6 +76,7 @@
DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViews.swift; sourceTree = "<group>"; };
F219788AE5ACBD6F240674F5 /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = "<group>"; };
FC338B05EA6DB22900712000 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = "<group>"; };
FEC6F837FF2E902E334ED72E /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+App.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -274,7 +274,7 @@
children = (
9D83BB88C4306BE7A4F947CB /* Color+App.swift */,
7CAFB96D2500F34F0B0C860C /* NavDestination.swift */,
B2C3D4E5F67890123456789A /* String+App.swift */,
FEC6F837FF2E902E334ED72E /* String+App.swift */,
);
path = Extensions;
sourceTree = "<group>";
@@ -337,7 +337,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1600;
LastUpgradeCheck = 2630;
};
buildConfigurationList = D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */;
developmentRegion = en;
@@ -397,7 +397,6 @@
FEFB5FDC2424D22914458001 /* ChapterReaderView.swift in Sources */,
2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */,
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */,
A1B2C3D4E5F6789012345678 /* String+App.swift in Sources */,
F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */,
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */,
EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */,
@@ -411,6 +410,7 @@
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */,
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */,
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */,
41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -435,7 +435,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel.tests;
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel.tests;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LibNovel.app/LibNovel";
@@ -452,7 +452,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel.tests;
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel.tests;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LibNovel.app/LibNovel";
@@ -463,16 +463,21 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = GHZXC6FVMU;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel;
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel;
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
@@ -512,11 +517,12 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 1000;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_PREVIEWS = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
@@ -539,6 +545,7 @@
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.10;
@@ -549,16 +556,24 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_IDENTITY = "Apple Distribution";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = GHZXC6FVMU;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = GHZXC6FVMU;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel;
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel;
PROVISIONING_PROFILE = "af592c3a-f60b-4ac1-a14f-30b8a206017f";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LibNovel Distribution";
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
@@ -598,11 +613,12 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 1000;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_PREVIEWS = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@@ -618,6 +634,7 @@
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";

View File

@@ -1,5 +1,5 @@
{
"originHash" : "18350c2bfa3935125b6f4e9817e7ed4508588c07142d420b8b8ee00640a57853",
"originHash" : "ad75ae2d3b8d8b80d99635f65213a3c1092464aa54a86354f850b8317b6fa240",
"pins" : [
{
"identity" : "kingfisher",

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

@@ -13,12 +13,7 @@ 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 {
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)
}
}
modifier(AppNavigationDestinationModifier())
}
/// Presents a standard "Error" alert driven by an optional String binding.
@@ -34,3 +29,72 @@ extension View {
}
}
}
// MARK: - Navigation destination modifier
private struct AppNavigationDestinationModifier: ViewModifier {
@Namespace private var zoomNamespace
func body(content: Content) -> some View {
if #available(iOS 18.0, *) {
content
.navigationDestination(for: NavDestination.self) { dest in
switch dest {
case .book(let slug):
BookDetailView(slug: slug)
.navigationTransition(.zoom(sourceID: slug, in: zoomNamespace))
case .chapter(let slug, let n):
ChapterReaderView(slug: slug, chapterNumber: n)
}
}
// Expose namespace to child views via environment
.environment(\.bookZoomNamespace, zoomNamespace)
} else {
content
.navigationDestination(for: NavDestination.self) { dest in
switch dest {
case .book(let slug): BookDetailView(slug: slug)
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
}
}
}
}
}
// MARK: - Environment key for zoom namespace
struct BookZoomNamespaceKey: EnvironmentKey {
static var defaultValue: Namespace.ID? { nil }
}
extension EnvironmentValues {
var bookZoomNamespace: Namespace.ID? {
get { self[BookZoomNamespaceKey.self] }
set { self[BookZoomNamespaceKey.self] = newValue }
}
}
// MARK: - Cover card zoom source modifier
/// Apply this to any cover image that should be a zoom source for book navigation.
/// Falls back to a no-op on iOS 17 or when no namespace is available.
struct BookCoverZoomSource: ViewModifier {
let slug: String
@Environment(\.bookZoomNamespace) private var namespace
func body(content: Content) -> some View {
if #available(iOS 18.0, *), let ns = namespace {
content.matchedTransitionSource(id: slug, in: ns)
} else {
content
}
}
}
extension View {
/// Marks a cover image as the zoom source for a book's navigation transition.
func bookCoverZoomSource(slug: String) -> some View {
modifier(BookCoverZoomSource(slug: slug))
}
}

View File

@@ -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,15 +169,22 @@ 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(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)
}
}
@@ -147,12 +209,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
@@ -203,10 +259,3 @@ struct BookBrief: Codable {
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 {
@@ -95,13 +90,6 @@ actor APIClient {
}
}
func fetchRaw(_ path: String, method: String = "GET", body: Encodable? = nil) async throws -> (Data, HTTPURLResponse) {
let req = try makeRequest(path, method: method, body: body)
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
return (data, http)
}
// MARK: - Auth
struct LoginRequest: Encodable {
@@ -125,7 +113,7 @@ actor APIClient {
}
func logout() async throws {
let (_, _) = try await fetchRaw("/api/auth/logout", method: "POST")
let _: EmptyResponse = try await fetch("/api/auth/logout", method: "POST")
await setAuthCookie(nil)
}
@@ -163,13 +151,6 @@ actor APIClient {
// MARK: - Browse
struct BrowseParams: Encodable {
let page: Int
let genre: String
let sort: String
let status: String
}
func browse(page: Int, genre: String = "all", sort: String = "popular", status: String = "all") async throws -> BrowseResponse {
let query = "?page=\(page)&genre=\(genre)&sort=\(sort)&status=\(status)"
return try await fetch("/api/browse-page\(query)")
@@ -283,6 +264,55 @@ 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
}
}
// MARK: - Response types
@@ -353,11 +383,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

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

View File

@@ -2,6 +2,7 @@ import Foundation
import AVFoundation
import MediaPlayer
import Combine
import Kingfisher
// MARK: - PlaybackProgress
// Isolated ObservableObject for high-frequency playback state (currentTime,
@@ -64,6 +65,9 @@ final class AudioPlayerService: ObservableObject {
@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 = ""
@@ -75,20 +79,6 @@ final class AudioPlayerService: ObservableObject {
default: return true
}
}
/// Absolute previous chapter number (current - 1), or nil if at first chapter
var absolutePrevChapter: Int? {
guard chapter > 1 else { return nil }
return chapter - 1
}
/// Absolute next chapter number (current + 1), or nil if at last chapter
var absoluteNextChapter: Int? {
guard !chapters.isEmpty else { return nil }
let maxChapter = chapters.map(\.number).max() ?? chapter
guard chapter < maxChapter else { return nil }
return chapter + 1
}
// MARK: - Private
@@ -109,6 +99,10 @@ final class AudioPlayerService: ObservableObject {
// 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
@@ -196,31 +190,67 @@ final class AudioPlayerService: ObservableObject {
}
func setSleepTimer(_ option: SleepTimerOption?) {
// Cancel existing timer
// Cancel existing timer + countdown
sleepTimerTask?.cancel()
sleepTimerTask = nil
sleepTimerCountdownTask?.cancel()
sleepTimerCountdownTask = nil
sleepTimerDeadline = nil
sleepTimer = option
guard let option else { return }
guard let option else {
sleepTimerRemainingText = ""
return
}
// Start timer based on option
switch option {
case .chapters(let count):
sleepTimerStartChapter = chapter
// Monitor chapter changes in handlePlaybackFinished
// 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()
@@ -231,10 +261,14 @@ final class AudioPlayerService: ObservableObject {
audioURL = ""
status = .idle
// Cancel sleep timer
// Cancel sleep timer + countdown
sleepTimerTask?.cancel()
sleepTimerTask = nil
sleepTimerCountdownTask?.cancel()
sleepTimerCountdownTask = nil
sleepTimerDeadline = nil
sleepTimer = nil
sleepTimerRemainingText = ""
}
// MARK: - Audio generation
@@ -408,6 +442,9 @@ final class AudioPlayerService: ObservableObject {
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).
@@ -473,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

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)

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

@@ -1,4 +1,5 @@
import SwiftUI
import Kingfisher
struct BookDetailView: View {
let slug: String
@@ -15,18 +16,22 @@ struct BookDetailView: View {
}
var body: some View {
ScrollView {
if vm.isLoading {
ProgressView().frame(maxWidth: .infinity).padding(.top, 80)
} else if let book = vm.book {
ZStack(alignment: .top) {
// Scroll content
ScrollView {
VStack(alignment: .leading, spacing: 0) {
heroSection(book: book)
Divider().padding(.vertical, 8)
chapterSection(book: book)
if vm.isLoading {
ProgressView().frame(maxWidth: .infinity).padding(.top, 120)
} else if let book = vm.book {
heroSection(book: book)
metaSection(book: book)
Divider().padding(.horizontal)
chapterSection(book: book)
}
}
}
.ignoresSafeArea(edges: .top)
}
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar { bookmarkButton }
.task { await vm.load() }
@@ -38,85 +43,148 @@ struct BookDetailView: View {
@ViewBuilder
private func heroSection(book: Book) -> some View {
ZStack(alignment: .bottom) {
// Blurred cover background use plain colour placeholder to avoid
// the rounded-rect loading indicator showing through the blur.
AsyncCoverImage(url: book.cover, isBackground: true)
// Full-bleed blurred background
KFImage(URL(string: book.cover))
.resizable()
.scaledToFill()
.frame(maxWidth: .infinity)
.frame(height: 260)
.blur(radius: 20)
.frame(height: 320)
.blur(radius: 24)
.clipped()
.overlay(Color.black.opacity(0.45))
.overlay(
LinearGradient(
colors: [.black.opacity(0.15), .black.opacity(0.68)],
startPoint: .top,
endPoint: .bottom
)
)
HStack(alignment: .bottom, spacing: 14) {
AsyncCoverImage(url: book.cover)
.frame(width: 110, height: 160)
.clipShape(RoundedRectangle(cornerRadius: 10))
.shadow(radius: 8)
// Cover + info column centered
VStack(spacing: 16) {
// Isolated cover with 3D-style shadow
KFImage(URL(string: book.cover))
.resizable()
.placeholder {
RoundedRectangle(cornerRadius: 12)
.fill(Color(.systemGray5))
}
.scaledToFill()
.frame(width: 130, height: 188)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.55), radius: 18, x: 0, y: 10)
.shadow(color: .black.opacity(0.3), radius: 6, x: 0, y: 3)
VStack(alignment: .leading, spacing: 6) {
// Title + author
VStack(spacing: 6) {
Text(book.title)
.font(.headline)
.font(.title3.bold())
.foregroundStyle(.white)
.multilineTextAlignment(.center)
.lineLimit(3)
.padding(.horizontal, 32)
Text(book.author)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.8))
HStack {
// TagChip(label: book.status).colorScheme(.dark)
ForEach(book.genres.prefix(2), id: \.self) {
TagChip(label: $0).colorScheme(.dark)
.foregroundStyle(.white.opacity(0.75))
}
// Genre tags
if !book.genres.isEmpty {
HStack(spacing: 8) {
ForEach(book.genres.prefix(3), id: \.self) { genre in
TagChip(label: genre).colorScheme(.dark)
}
}
}
Spacer(minLength: 0)
// Status badge
if !book.status.isEmpty {
StatusBadge(status: book.status)
}
}
.padding(.horizontal)
.padding(.bottom, 16)
.padding(.bottom, 28)
}
.frame(minHeight: 320)
}
// Summary
VStack(alignment: .leading, spacing: 8) {
Text(book.summary)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(summaryExpanded ? nil : 4)
if book.summary.count > 200 {
Button(summaryExpanded ? "Less" : "More") {
withAnimation { summaryExpanded.toggle() }
// MARK: - Meta section (summary + CTAs)
@ViewBuilder
private func metaSection(book: Book) -> some View {
VStack(alignment: .leading, spacing: 0) {
// Quick stats row
HStack(spacing: 0) {
MetaStat(value: "\(book.totalChapters)", label: "Chapters",
icon: "doc.text")
Divider().frame(height: 36)
MetaStat(value: book.status.capitalized.isEmpty ? "" : book.status.capitalized,
label: "Status", icon: "flag")
if book.ranking > 0 {
Divider().frame(height: 36)
MetaStat(value: "#\(book.ranking)", label: "Rank",
icon: "chart.bar.fill")
}
.font(.caption.bold()) 
.foregroundStyle(.amber)
}
}
.padding()
.padding(.vertical, 16)
.frame(maxWidth: .infinity)
// CTA buttons
HStack(spacing: 10) {
if let last = vm.lastChapter, last > 0 {
NavigationLink(value: NavDestination.chapter(slug, last)) {
Label("Continue Ch.\(last)", systemImage: "play.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.amber)
Divider().padding(.horizontal)
NavigationLink(value: NavDestination.chapter(slug, 1)) {
Label("From Ch.1", systemImage: "arrow.counterclockwise")
.frame(maxWidth: .infinity)
// Summary
VStack(alignment: .leading, spacing: 8) {
Text("About")
.font(.headline)
Text(book.summary)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(summaryExpanded ? nil : 4)
.animation(.easeInOut(duration: 0.2), value: summaryExpanded)
if book.summary.count > 200 {
Button(summaryExpanded ? "Less" : "More") {
withAnimation { summaryExpanded.toggle() }
}
.font(.caption.bold())
.foregroundStyle(.amber)
}
.buttonStyle(.bordered)
.tint(.secondary)
} else {
NavigationLink(value: NavDestination.chapter(slug, 1)) {
Label("Start Reading", systemImage: "book.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.amber)
}
.padding(.horizontal)
.padding(.vertical, 16)
Divider().padding(.horizontal)
// CTA buttons
HStack(spacing: 10) {
if let last = vm.lastChapter, last > 0 {
NavigationLink(value: NavDestination.chapter(slug, last)) {
Label("Continue Ch.\(last)", systemImage: "play.fill")
.frame(maxWidth: .infinity)
.fontWeight(.semibold)
}
.buttonStyle(.borderedProminent)
.tint(.amber)
NavigationLink(value: NavDestination.chapter(slug, 1)) {
Label("Ch.1", systemImage: "arrow.counterclockwise")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.secondary)
} else {
NavigationLink(value: NavDestination.chapter(slug, 1)) {
Label("Start Reading", systemImage: "book.fill")
.frame(maxWidth: .infinity)
.fontWeight(.semibold)
}
.buttonStyle(.borderedProminent)
.tint(.amber)
}
}
.padding(.horizontal)
.padding(.vertical, 16)
}
.padding(.horizontal)
.padding(.bottom, 8)
}
// MARK: - Chapter list
@@ -130,9 +198,10 @@ struct BookDetailView: View {
let pageChapters = Array(chapters[start..<end])
VStack(alignment: .leading, spacing: 0) {
// Section header
HStack {
Text("Chapters")
.font(.title3.bold())
.font(.headline)
Spacer()
if total > 0 {
Text("\(start + 1)\(end) of \(total)")
@@ -141,36 +210,58 @@ struct BookDetailView: View {
}
}
.padding(.horizontal)
.padding(.vertical, 10)
.padding(.vertical, 14)
if vm.chaptersLoading {
if vm.isLoading {
ProgressView().frame(maxWidth: .infinity).padding()
} else {
ForEach(pageChapters) { ch in
NavigationLink(value: NavDestination.chapter(slug, ch.number)) {
ChapterRow(chapter: ch, isCurrent: ch.number == vm.lastChapter)
ChapterRow(chapter: ch, isCurrent: ch.number == vm.lastChapter,
totalChapters: total)
}
.buttonStyle(.plain)
Divider().padding(.leading)
}
}
// Pagination
// Pagination bar
if total > pageSize {
HStack {
Button("Previous") { chapterPage -= 1 }
.disabled(chapterPage == 0)
Button {
withAnimation { chapterPage -= 1 }
} label: {
Image(systemName: "chevron.left")
Text("Previous")
}
.disabled(chapterPage == 0)
Spacer()
Button("Next") { chapterPage += 1 }
.disabled(end >= total)
Text("Page \(chapterPage + 1) of \((total + pageSize - 1) / pageSize)")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button {
withAnimation { chapterPage += 1 }
} label: {
Text("Next")
Image(systemName: "chevron.right")
}
.disabled(end >= total)
}
.buttonStyle(.bordered)
.font(.subheadline)
.foregroundStyle(.amber)
.padding()
}
Color.clear.frame(height: 32)
}
}
// MARK: - Toolbar bookmark
// MARK: - Bookmark toolbar
@ToolbarContentBuilder
private var bookmarkButton: some ToolbarContent {
@@ -185,38 +276,113 @@ struct BookDetailView: View {
}
}
// MARK: - Chapter row
private struct ChapterRow: View {
let chapter: ChapterIndex
let isCurrent: Bool
let totalChapters: Int
private var progressFraction: Double {
guard totalChapters > 1 else { return 0 }
return Double(chapter.number) / Double(totalChapters)
}
var body: some View {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text("Chapter \(chapter.number)")
.font(.subheadline)
.fontWeight(isCurrent ? .bold : .regular)
.foregroundStyle(isCurrent ? .amber : .primary)
if !chapter.title.isEmpty && chapter.title != "Chapter \(chapter.number)" {
Text(chapter.title)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
HStack(spacing: 10) {
// Number badge
ZStack {
Circle()
.fill(isCurrent ? Color.amber : Color(.systemGray6))
Text("\(chapter.number)")
.font(.caption2.bold().monospacedDigit())
.foregroundStyle(isCurrent ? .black : .secondary)
}
Spacer(minLength: 12)
HStack(spacing: 6) {
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 2) {
let displayTitle: String = {
let stripped = chapter.title.strippingTrailingDate()
if stripped.isEmpty || stripped == "Chapter \(chapter.number)" {
return "Chapter \(chapter.number)"
}
return stripped
}()
Text(displayTitle)
.font(.subheadline)
.fontWeight(isCurrent ? .semibold : .regular)
.foregroundStyle(isCurrent ? .amber : .primary)
.lineLimit(1)
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 2) {
if !chapter.dateLabel.isEmpty {
Text(chapter.dateLabel)
.font(.caption2)
.foregroundStyle(.tertiary)
.fixedSize()
}
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(.tertiary)
}
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.padding(.horizontal)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.contentShape(Rectangle())
}
}
// MARK: - Supporting components
private struct MetaStat: View {
let value: String
let label: String
let icon: String
var body: some View {
VStack(spacing: 4) {
Image(systemName: icon)
.font(.caption)
.foregroundStyle(.amber)
Text(value)
.font(.subheadline.bold())
.lineLimit(1)
.minimumScaleFactor(0.7)
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
}
private struct StatusBadge: View {
let status: String
private var color: Color {
switch status.lowercased() {
case "ongoing", "active": return .green
case "completed": return .blue
case "hiatus": return .orange
default: return .secondary
}
}
var body: some View {
HStack(spacing: 4) {
Circle()
.fill(color)
.frame(width: 6, height: 6)
Text(status.capitalized)
.font(.caption.weight(.medium))
.foregroundStyle(color)
}
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(color.opacity(0.12), in: Capsule())
}
}

View File

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

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

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

View File

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

View File

@@ -204,280 +204,264 @@ struct FullPlayerView: View {
/// Called when the view wants to close itself (Done button or drag-to-dismiss).
var onDismiss: () -> Void = {}
@State private var showingSpeedMenu = false
@State private var showingChaptersList = false
@State private var showingSleepTimer = false
var body: some View {
ZStack {
// Background: blurred cover art
GeometryReader { geo in
GeometryReader { geo in
ZStack {
// Background: blurred cover art
KFImage(URL(string: audioPlayer.coverURL))
.resizable()
.scaledToFill()
.frame(width: geo.size.width, height: geo.size.height)
.clipped()
.blur(radius: 40, opaque: true)
.overlay(Color.black.opacity(0.55))
.blur(radius: 50, opaque: true)
.overlay(Color.black.opacity(0.5))
.ignoresSafeArea()
}
.ignoresSafeArea()
// Content
VStack(spacing: 0) {
// Drag handle pill visual cue that you can swipe down to close
Capsule()
.fill(Color.white.opacity(0.35))
.frame(width: 36, height: 4)
.padding(.top, 12)
.padding(.bottom, 16)
// Content
VStack(spacing: 0) {
// Drag handle
Capsule()
.fill(Color.white.opacity(0.3))
.frame(width: 36, height: 4)
.padding(.top, 14)
// Cover art with watermark
ZStack(alignment: .bottomLeading) {
KFImage(URL(string: audioPlayer.coverURL))
.resizable()
.placeholder {
RoundedRectangle(cornerRadius: 18)
.fill(.white.opacity(0.1))
.overlay(
Image(systemName: "book.closed")
.font(.system(size: 48))
.foregroundStyle(.white.opacity(0.4))
)
// Cover art
// Scales to fill ~55 % of screen height minus chrome
let coverSize = min(geo.size.width - 56, geo.size.height * 0.42)
ZStack {
KFImage(URL(string: audioPlayer.coverURL))
.resizable()
.placeholder {
RoundedRectangle(cornerRadius: 20)
.fill(.white.opacity(0.08))
.overlay(
Image(systemName: "book.closed")
.font(.system(size: 56))
.foregroundStyle(.white.opacity(0.3))
)
}
.scaledToFill()
.frame(width: coverSize, height: coverSize)
.clipShape(RoundedRectangle(cornerRadius: 20))
.shadow(color: .black.opacity(0.6), radius: 32, y: 16)
// Dim cover while generating
.overlay(
RoundedRectangle(cornerRadius: 20)
.fill(Color.black.opacity(audioPlayer.status == .generating ? 0.45 : 0))
)
// Generating spinner centred over cover
if audioPlayer.status == .generating {
VStack(spacing: 10) {
ProgressView()
.tint(.white)
.scaleEffect(1.4)
Text("Generating audio…")
.font(.caption.weight(.medium))
.foregroundStyle(.white.opacity(0.75))
}
}
.scaledToFill()
.frame(width: 240, height: 240)
.clipShape(RoundedRectangle(cornerRadius: 18))
.shadow(color: .black.opacity(0.5), radius: 24, y: 12)
// Watermark (voice name from audio player)
Text(voiceName)
.font(.custom("Snell Roundhand", size: 20))
.foregroundStyle(.white.opacity(0.7))
.shadow(color: .black.opacity(0.4), radius: 2)
.padding(14)
}
.padding(.horizontal, 48)
// Title block
VStack(spacing: 4) {
Text((audioPlayer.chapterTitle.isEmpty ? "Chapter \(audioPlayer.chapter)" : audioPlayer.chapterTitle).strippingTrailingDate())
.font(.title3.weight(.bold))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
.lineLimit(2)
Text(audioPlayer.bookTitle)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.65))
.lineLimit(1)
}
.padding(.horizontal, 32)
.padding(.top, 20)
// Action buttons row + metadata inline
HStack(spacing: 0) {
Spacer()
// Metadata pill (only when ready)
if audioPlayer.status != .generating {
Text("\(yearText) · \(cacheStatusText) · OPUS")
.font(.caption2)
.foregroundStyle(.white.opacity(0.35))
.padding(.horizontal, 8)
// Voice watermark bottom-left corner of cover
VStack {
Spacer()
HStack {
Text(voiceName)
.font(.custom("Snell Roundhand", size: 18))
.foregroundStyle(.white.opacity(0.55))
.shadow(color: .black.opacity(0.5), radius: 2)
.padding(12)
Spacer()
}
}
.frame(width: coverSize, height: coverSize)
}
Menu {
.frame(width: coverSize, height: coverSize)
.padding(.top, 20)
// Title block
HStack(alignment: .center, spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text((audioPlayer.chapterTitle.isEmpty
? "Chapter \(audioPlayer.chapter)"
: audioPlayer.chapterTitle).strippingTrailingDate())
.font(.title3.weight(.bold))
.foregroundStyle(.white)
.lineLimit(2)
Text(audioPlayer.bookTitle)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.6))
.lineLimit(1)
if !audioPlayer.chapters.isEmpty {
Text(chapterPositionText)
.font(.caption2.monospacedDigit())
.foregroundStyle(.white.opacity(0.35))
.padding(.top, 1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
// Auto-next toggle (heart-like button on right of title)
Button {
audioPlayer.autoNext.toggle()
} label: {
Label(
audioPlayer.autoNext ? "Disable Auto-next" : "Enable Auto-next",
systemImage: audioPlayer.autoNext ? "checkmark" : ""
)
Image(systemName: audioPlayer.autoNext ? "infinity.circle.fill" : "infinity.circle")
.font(.system(size: 28))
.foregroundStyle(audioPlayer.autoNext ? Color.amber : .white.opacity(0.45))
.contentTransition(.symbolEffect(.replace))
}
} label: {
Image(systemName: "ellipsis.circle")
.font(.system(size: 22))
.foregroundStyle(.white.opacity(0.6))
.frame(width: 40, height: 40)
.buttonStyle(.plain)
}
.buttonStyle(.plain)
Spacer()
}
.padding(.top, 10)
.padding(.horizontal, 28)
.padding(.top, 22)
// Seek bar hidden while generating
if audioPlayer.status != .generating {
// Seek bar
PlayerProgressSection(
progress: audioPlayer.progress,
onSeek: { audioPlayer.seek(to: $0) }
)
} else {
// Generating state: compact progress indicator with label
VStack(spacing: 8) {
ProgressView()
.tint(.white.opacity(0.7))
.scaleEffect(1.1)
Text("Generating audio…")
.font(.caption)
.foregroundStyle(.white.opacity(0.5))
}
.frame(maxWidth: .infinity)
.padding(.top, 20)
.padding(.bottom, 4)
}
.padding(.top, 18)
.opacity(audioPlayer.status == .generating ? 0.3 : 1)
.allowsHitTesting(audioPlayer.status != .generating)
// Controls
HStack(spacing: 0) {
// skip back 15s
Button { audioPlayer.skip(by: -15) } label: {
Image(systemName: "gobackward.15")
.font(.system(size: 22, weight: .regular))
.foregroundStyle(.white.opacity(audioPlayer.status == .generating ? 0.3 : 0.9))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(audioPlayer.status == .generating)
// previous chapter
Button {
if let prev = audioPlayer.absolutePrevChapter {
onDismiss()
NotificationCenter.default.post(
name: .skipToPrevChapter,
object: nil,
userInfo: ["prev": prev]
)
// Transport controls
// Layout: [skip-15] [prev-ch] [PLAY/PAUSE] [next-ch] [skip+15]
// Outer skip buttons are smaller; prev/next are medium; center is large circle
HStack(spacing: 0) {
// skip 15 s
PlayerSecondaryButton(
systemName: "gobackward.15",
size: 24,
disabled: audioPlayer.status == .generating
) { audioPlayer.skip(by: -15) }
// previous chapter
PlayerChapterSkipButton(
systemName: "backward.end.fill",
size: 30,
disabled: audioPlayer.absolutePrevChapter == nil
) {
if let prev = audioPlayer.absolutePrevChapter {
onDismiss()
NotificationCenter.default.post(
name: .skipToPrevChapter, object: nil,
userInfo: ["prev": prev]
)
}
}
} label: {
Image(systemName: "backward.end.fill")
.font(.system(size: 28, weight: .regular))
.foregroundStyle(.white.opacity(0.9))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(audioPlayer.absolutePrevChapter == nil)
.opacity(audioPlayer.absolutePrevChapter == nil ? 0.4 : 1.0)
// play / pause large circle button
PlayerPlayPauseButton(
progress: audioPlayer.progress,
isGenerating: audioPlayer.status == .generating,
onToggle: { audioPlayer.togglePlayPause() }
)
// Play / pause large circle
PlayerPlayPauseButton(
progress: audioPlayer.progress,
isGenerating: audioPlayer.status == .generating,
onToggle: { audioPlayer.togglePlayPause() }
)
// next chapter
Button {
if let next = audioPlayer.absoluteNextChapter {
onDismiss()
NotificationCenter.default.post(
name: .skipToNextChapter,
object: nil,
userInfo: ["next": next]
)
// next chapter
PlayerChapterSkipButton(
systemName: "forward.end.fill",
size: 30,
disabled: audioPlayer.absoluteNextChapter == nil,
prefetching: audioPlayer.nextPrefetchStatus == .prefetching
) {
if let next = audioPlayer.absoluteNextChapter {
onDismiss()
NotificationCenter.default.post(
name: .skipToNextChapter, object: nil,
userInfo: ["next": next]
)
}
}
} label: {
ZStack {
Image(systemName: "forward.end.fill")
.font(.system(size: 28, weight: .regular))
.foregroundStyle(.white.opacity(0.9))
if audioPlayer.nextPrefetchStatus == .prefetching {
VStack {
Spacer()
HStack {
Spacer()
ProgressView()
.scaleEffect(0.55)
.tint(.amber)
.padding(3)
.background(Circle().fill(.black.opacity(0.6)))
// skip 15 s
PlayerSecondaryButton(
systemName: "goforward.15",
size: 24,
disabled: audioPlayer.status == .generating
) { audioPlayer.skip(by: 15) }
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 8)
// Bottom toolbar
// AirPlay | Speed | Chevron-down | List | Moon
HStack(spacing: 0) {
// AirPlay
AirPlayButton()
.frame(width: 24, height: 24)
.frame(maxWidth: .infinity)
// Speed
Menu {
ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0], id: \.self) { s in
Button {
audioPlayer.setSpeed(s)
} label: {
if s == audioPlayer.speed {
Label("\(s, specifier: "%.2g")×", systemImage: "checkmark")
} else {
Text("\(s, specifier: "%.2g")×")
}
}
}
} label: {
Text("\(audioPlayer.speed, specifier: "%.2g")×")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity)
.frame(height: 44)
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(audioPlayer.absoluteNextChapter == nil)
.opacity(audioPlayer.absoluteNextChapter == nil ? 0.4 : 1.0)
// skip forward 15s
Button { audioPlayer.skip(by: 15) } label: {
Image(systemName: "goforward.15")
.font(.system(size: 22, weight: .regular))
.foregroundStyle(.white.opacity(audioPlayer.status == .generating ? 0.3 : 0.9))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(audioPlayer.status == .generating)
}
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, 20)
.buttonStyle(.plain)
// Bottom toolbar
HStack(spacing: 0) {
// AirPlay
AirPlayButton()
.frame(width: 22, height: 22)
.frame(maxWidth: .infinity)
// Speed control
Menu {
ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0], id: \.self) { s in
Button {
audioPlayer.setSpeed(s)
} label: {
if s == audioPlayer.speed {
Label("\(s, specifier: "%.2g")×", systemImage: "checkmark")
} else {
Text("\(s, specifier: "%.2g")×")
// Collapse
Button { onDismiss() } label: {
Image(systemName: "chevron.down")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity)
.frame(height: 44)
}
.buttonStyle(.plain)
// Chapters list
Button { showingChaptersList = true } label: {
Image(systemName: "list.bullet")
.font(.system(size: 20))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity)
.frame(height: 44)
}
.buttonStyle(.plain)
// Sleep timer
Button { showingSleepTimer = true } label: {
VStack(spacing: 1) {
Image(systemName: sleepTimerIcon)
.font(.system(size: 20))
.foregroundStyle(audioPlayer.sleepTimer != nil ? Color.amber : .white.opacity(0.7))
if !audioPlayer.sleepTimerRemainingText.isEmpty {
Text(audioPlayer.sleepTimerRemainingText)
.font(.system(size: 9, weight: .semibold).monospacedDigit())
.foregroundStyle(Color.amber)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity)
.frame(height: 44)
}
} label: {
// Show current speed as a badge instead of gear icon
Text("\(audioPlayer.speed, specifier: "%.2g")×")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity)
.buttonStyle(.plain)
}
.buttonStyle(.plain)
// Collapse
Button { onDismiss() } label: {
Image(systemName: "chevron.down")
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
// Queue (show chapters list)
Button { showingChaptersList = true } label: {
Image(systemName: "list.bullet")
.font(.system(size: 22))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
// Sleep timer
Button { showingSleepTimer = true } label: {
Image(systemName: sleepTimerIcon)
.font(.system(size: 22))
.foregroundStyle(audioPlayer.sleepTimer != nil ? .amber : .white.opacity(0.7))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.padding(.horizontal, 12)
.padding(.bottom, 8)
}
.padding(.horizontal, 16)
.padding(.bottom, 12)
.ignoresSafeArea(edges: .bottom)
}
.ignoresSafeArea(edges: .bottom)
}
.ignoresSafeArea()
.sheet(isPresented: $showingChaptersList) {
ChaptersListSheet(
chapters: audioPlayer.chapters,
@@ -487,7 +471,6 @@ struct FullPlayerView: View {
guard selectedChapter != audioPlayer.chapter else { return }
let currentAudioChapter = audioPlayer.chapter
// Find the chapter metadata from the loaded list
let chapterTitle = audioPlayer.chapters
.first(where: { $0.number == selectedChapter })?.title ?? ""
let nextChapter = audioPlayer.chapters
@@ -495,7 +478,6 @@ struct FullPlayerView: View {
.min(by: { $0.number < $1.number })?.number
let prevChapter: Int? = selectedChapter > 1 ? selectedChapter - 1 : nil
// Load & start playing the selected chapter directly
audioPlayer.load(
slug: audioPlayer.slug,
chapter: selectedChapter,
@@ -509,14 +491,11 @@ struct FullPlayerView: View {
prevChapter: prevChapter
)
// Also navigate the text reader if it's open
let notifName: Notification.Name = selectedChapter > currentAudioChapter
? .skipToNextChapter
: .skipToPrevChapter
? .skipToNextChapter : .skipToPrevChapter
let key = selectedChapter > currentAudioChapter ? "next" : "prev"
NotificationCenter.default.post(
name: notifName,
object: nil,
name: notifName, object: nil,
userInfo: [key: selectedChapter]
)
}
@@ -531,43 +510,89 @@ struct FullPlayerView: View {
}
}
// MARK: - Helpers
private var chapterPositionText: String {
let total = audioPlayer.chapters.count
guard total > 0 else { return "" }
let sorted = audioPlayer.chapters.sorted(by: { $0.number < $1.number })
let idx = (sorted.firstIndex(where: { $0.number == audioPlayer.chapter }) ?? 0) + 1
return "Chapter \(idx) of \(total)"
}
private var voiceName: String {
// Extract voice name from audioPlayer.voice (e.g., "af_bella" -> "Bella")
let components = audioPlayer.voice.split(separator: "_")
if components.count > 1 {
return String(components[1]).capitalized
}
if components.count > 1 { return String(components[1]).capitalized }
return audioPlayer.voice.capitalized
}
private var cacheStatusText: String {
switch audioPlayer.status {
case .ready:
return "Cache"
case .generating:
return "Generating"
default:
return "Unknown"
}
}
private static let yearFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy"
return f
}()
private var yearText: String {
// TODO: Could fetch actual publication year from book metadata
// For now, return current year or placeholder
return Self.yearFormatter.string(from: Date())
}
private var sleepTimerIcon: String {
audioPlayer.sleepTimer != nil ? "moon.zzz.fill" : "moon.zzz"
}
}
// MARK: - Small secondary transport button (±15 s skips)
private struct PlayerSecondaryButton: View {
let systemName: String
let size: CGFloat
let disabled: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: size, weight: .regular))
.foregroundStyle(.white.opacity(disabled ? 0.3 : 0.85))
.frame(maxWidth: .infinity)
.frame(height: 64)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(disabled)
}
}
// MARK: - Medium chapter-skip button (prev / next chapter)
private struct PlayerChapterSkipButton: View {
let systemName: String
let size: CGFloat
let disabled: Bool
var prefetching: Bool = false
let action: () -> Void
var body: some View {
Button(action: action) {
ZStack {
Image(systemName: systemName)
.font(.system(size: size, weight: .regular))
.foregroundStyle(.white.opacity(disabled ? 0.3 : 0.9))
if prefetching {
VStack {
Spacer()
HStack {
Spacer()
ProgressView()
.scaleEffect(0.55)
.tint(.amber)
.padding(3)
.background(Circle().fill(.black.opacity(0.6)))
}
}
}
}
.frame(maxWidth: .infinity)
.frame(height: 64)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(disabled)
.opacity(disabled ? 0.4 : 1.0)
}
}
// MARK: - AirPlay Button
struct AirPlayButton: UIViewControllerRepresentable {
@@ -686,93 +711,223 @@ struct SleepTimerSheet: View {
}
// MARK: - Chapters List Sheet
// Apple Books-style: chapters grouped into blocks of 100 with a sticky jump
// bar along the right edge. A search bar filters by number or title.
struct ChaptersListSheet: View {
let chapters: [ChapterIndexBrief]
let currentChapter: Int
let onChapterSelect: (Int) -> Void
@Environment(\.dismiss) private var dismiss
// Initialize scroll position to current chapter immediately (before view appears)
init(chapters: [ChapterIndexBrief], currentChapter: Int, onChapterSelect: @escaping (Int) -> Void) {
self.chapters = chapters
self.currentChapter = currentChapter
self.onChapterSelect = onChapterSelect
// Set initial scroll position state before view renders
_scrollPosition = State(initialValue: currentChapter)
@State private var searchText: String = ""
/// The block label the jump bar is currently scrolling to (e.g. "1100").
@State private var activeBlock: String? = nil
// MARK: Derived data
/// Chapters matching the current search query (or all chapters if empty).
private var filtered: [ChapterIndexBrief] {
guard !searchText.isEmpty else { return chapters }
let q = searchText.lowercased()
return chapters.filter {
"\($0.number)".contains(q) || $0.title.lowercased().contains(q)
}
}
@State private var scrollPosition: Int?
/// Chapters grouped into blocks of 100: ["1100": [...], "101200": [...], ]
/// When the user is searching we use a single "Results" group so the jump
/// bar hides and the flat list is shown directly.
private var groups: [(label: String, chapters: [ChapterIndexBrief])] {
guard searchText.isEmpty else {
return filtered.isEmpty ? [] : [("Results", filtered)]
}
guard !chapters.isEmpty else { return [] }
let blockSize = 100
let minN = chapters.map(\.number).min() ?? 1
let maxN = chapters.map(\.number).max() ?? 1
// Round down to the nearest block boundary for the first block start.
let firstBlock = ((minN - 1) / blockSize) * blockSize + 1
var result: [(label: String, chapters: [ChapterIndexBrief])] = []
var blockStart = firstBlock
while blockStart <= maxN {
let blockEnd = blockStart + blockSize - 1
let slice = chapters.filter { $0.number >= blockStart && $0.number <= blockEnd }
if !slice.isEmpty {
result.append(("\(blockStart)\(blockEnd)", slice))
}
blockStart += blockSize
}
return result
}
/// Jump-bar labels (shown only when not searching).
private var jumpLabels: [String] { groups.map(\.label) }
// MARK: Body
var body: some View {
NavigationStack {
List {
ForEach(chapters, id: \.number) { chapter in
Button {
onChapterSelect(chapter.number)
} label: {
HStack(spacing: 12) {
// Chapter number badge
Text("\(chapter.number)")
.font(.caption.bold())
.foregroundStyle(chapter.number == currentChapter ? .white : .secondary)
.frame(width: 44, height: 44)
.background(
Circle()
.fill(chapter.number == currentChapter ? Color.amber : Color.gray.opacity(0.2))
ZStack(alignment: .trailing) {
// Main chapter list
List {
ForEach(groups, id: \.label) { group in
// Section header shows block range (e.g. "1100")
Section {
ForEach(group.chapters, id: \.number) { ch in
ChapterRow(
chapter: ch,
isCurrent: ch.number == currentChapter,
onSelect: { onChapterSelect(ch.number) }
)
// Chapter title
VStack(alignment: .leading, spacing: 4) {
Text(chapter.title.strippingTrailingDate())
.font(.subheadline.weight(chapter.number == currentChapter ? .semibold : .regular))
.foregroundStyle(chapter.number == currentChapter ? .primary : .primary)
.lineLimit(2)
if chapter.number == currentChapter {
Text("Now Playing")
.font(.caption2)
.foregroundStyle(.amber)
}
.id(group.label) // anchor for jump-bar scrollTo
}
Spacer()
// Checkmark for current chapter
if chapter.number == currentChapter {
Image(systemName: "checkmark")
} header: {
if searchText.isEmpty {
Text(group.label)
.font(.caption.bold())
.foregroundStyle(.amber)
.foregroundStyle(.secondary)
.id("header_\(group.label)")
}
}
.padding(.vertical, 8)
}
.buttonStyle(.plain)
.listRowBackground(
chapter.number == currentChapter
? Color.amber.opacity(0.1)
: Color.clear
)
.id(chapter.number)
}
.listStyle(.plain)
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Chapter number or title")
.scrollPosition(id: $activeBlock, anchor: .top)
// Right-edge jump bar (hidden while searching)
if searchText.isEmpty && jumpLabels.count > 1 {
JumpBar(labels: jumpLabels, currentChapter: currentChapter, groups: groups) { label in
withAnimation { activeBlock = label }
}
.padding(.trailing, 4)
}
}
.listStyle(.plain)
.scrollPosition(id: $scrollPosition, anchor: .center)
.navigationTitle("Chapters")
.navigationTitle("Chapters (\(chapters.count))")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
dismiss()
}
.fontWeight(.semibold)
Button("Done") { dismiss() }
.fontWeight(.semibold)
}
}
// Scroll to the currently playing chapter's block on first appear.
.onAppear {
if let block = groups.first(where: { g in
g.chapters.contains(where: { $0.number == currentChapter })
}) {
activeBlock = block.label
}
}
}
}
}
// MARK: - Individual chapter row
private struct ChapterRow: View {
let chapter: ChapterIndexBrief
let isCurrent: Bool
let onSelect: () -> Void
var body: some View {
Button(action: onSelect) {
HStack(spacing: 14) {
// Number badge
Text("\(chapter.number)")
.font(.caption.bold())
.foregroundStyle(isCurrent ? .white : .secondary)
.frame(width: 40, height: 40)
.background(
Circle().fill(isCurrent ? Color.amber : Color(.systemGray5))
)
// Title + "Now Playing" subtitle
VStack(alignment: .leading, spacing: 3) {
Text(chapter.title.strippingTrailingDate())
.font(.subheadline.weight(isCurrent ? .semibold : .regular))
.foregroundStyle(.primary)
.lineLimit(2)
if isCurrent {
Label("Now Playing", systemImage: "waveform")
.font(.caption2)
.foregroundStyle(.amber)
}
}
Spacer()
if isCurrent {
Image(systemName: "speaker.wave.2.fill")
.font(.caption.bold())
.foregroundStyle(.amber)
}
}
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.listRowBackground(isCurrent ? Color.amber.opacity(0.08) : Color.clear)
}
}
// MARK: - Right-edge jump bar
// A thin vertical strip on the right side of the sheet with block labels.
// Tapping or dragging a label jumps the list to that block instantly
// exactly like the Contacts AZ bar or Apple Books chapter scrubber.
private struct JumpBar: View {
let labels: [String]
let currentChapter: Int
let groups: [(label: String, chapters: [ChapterIndexBrief])]
let onSelect: (String) -> Void
@State private var isDragging = false
/// Short display label for each block: "1100" "1" etc.
private func shortLabel(_ full: String) -> String {
full.components(separatedBy: "").first ?? full
}
/// Which block contains the currently playing chapter.
private var currentBlock: String? {
groups.first(where: { g in g.chapters.contains(where: { $0.number == currentChapter }) })?.label
}
var body: some View {
VStack(spacing: 0) {
ForEach(labels, id: \.self) { label in
let isCurrent = label == currentBlock
Text(shortLabel(label))
.font(.system(size: 10, weight: isCurrent ? .bold : .regular))
.foregroundStyle(isCurrent ? Color.amber : Color.secondary)
.frame(width: 28, height: 28)
.contentShape(Rectangle())
.onTapGesture { onSelect(label) }
}
}
.padding(.vertical, 6)
.background(
Capsule()
.fill(.ultraThinMaterial)
.shadow(color: .black.opacity(0.15), radius: 4)
)
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onChanged { value in
isDragging = true
let itemHeight: CGFloat = 28
let index = Int(value.location.y / itemHeight)
let clamped = max(0, min(labels.count - 1, index))
onSelect(labels[clamped])
}
.onEnded { _ in isDragging = false }
)
.animation(.easeInOut(duration: 0.15), value: isDragging)
}
}
// MARK: - Custom seek slider
// A thicker, rounded-thumb slider that matches the amber design language.

View File

@@ -1,31 +1,99 @@
import SwiftUI
import PhotosUI
import Kingfisher
struct ProfileView: View {
@EnvironmentObject var authStore: AuthStore
@StateObject private var vm = ProfileViewModel()
@State private var showChangePassword = false
// Avatar upload state
@State private var photoPickerItem: PhotosPickerItem?
@State private var avatarURL: String? = nil
@State private var avatarUploading = false
@State private var avatarError: String?
var body: some View {
NavigationStack {
List {
// User header
// User header
Section {
HStack(spacing: 14) {
Image(systemName: "person.circle.fill")
.font(.system(size: 48))
.foregroundStyle(.amber)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 16) {
// Tappable avatar circle
PhotosPicker(selection: $photoPickerItem,
matching: .images,
photoLibrary: .shared()) {
ZStack {
Circle()
.fill(Color(.systemGray5))
.frame(width: 72, height: 72)
if avatarUploading {
ProgressView()
.frame(width: 72, height: 72)
} else if let urlStr = avatarURL ?? authStore.user?.avatarURL,
let url = URL(string: urlStr) {
KFImage(url)
.placeholder {
Image(systemName: "person.circle.fill")
.font(.system(size: 52))
.foregroundStyle(.amber)
}
.resizable()
.scaledToFill()
.frame(width: 72, height: 72)
.clipShape(Circle())
} else {
Image(systemName: "person.circle.fill")
.font(.system(size: 52))
.foregroundStyle(.amber)
.frame(width: 72, height: 72)
}
// Camera overlay badge
if !avatarUploading {
VStack {
Spacer()
HStack {
Spacer()
ZStack {
Circle()
.fill(Color.amber)
.frame(width: 22, height: 22)
Image(systemName: "camera.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.black)
}
.offset(x: 2, y: 2)
}
}
.frame(width: 72, height: 72)
}
}
}
.buttonStyle(.plain)
.onChange(of: photoPickerItem) { _, item in
guard let item else { return }
Task { await uploadPickedPhoto(item) }
}
VStack(alignment: .leading, spacing: 3) {
Text(authStore.user?.username ?? "")
.font(.headline)
Text(authStore.user?.role.capitalized ?? "")
.font(.caption)
.foregroundStyle(.secondary)
if let err = avatarError {
Text(err)
.font(.caption2)
.foregroundStyle(.red)
}
}
}
.padding(.vertical, 6)
}
// Reading settings
// Reading settings
Section("Reading Settings") {
voicePicker
speedSlider
@@ -42,7 +110,7 @@ struct ProfileView: View {
.tint(.amber)
}
// Sessions
// Sessions
Section("Active Sessions") {
if vm.sessionsLoading {
ProgressView()
@@ -55,7 +123,7 @@ struct ProfileView: View {
}
}
// Account
// Account
Section("Account") {
Button("Change Password") { showChangePassword = true }
Button("Sign Out", role: .destructive) {
@@ -64,7 +132,9 @@ struct ProfileView: View {
}
}
.navigationTitle("Profile")
.task { await vm.loadSessions() }
.task {
await vm.loadSessions()
}
.sheet(isPresented: $showChangePassword) {
ChangePasswordView()
}
@@ -72,6 +142,39 @@ struct ProfileView: View {
}
}
// MARK: - Avatar upload
private func uploadPickedPhoto(_ item: PhotosPickerItem) async {
avatarUploading = true
avatarError = nil
defer { avatarUploading = false }
do {
guard let data = try await item.loadTransferable(type: Data.self) else {
avatarError = "Could not read image"
return
}
// Compress to JPEG for consistent handling
let mimeType: String
let uploadData: Data
if let uiImage = UIImage(data: data),
let jpeg = uiImage.jpegData(compressionQuality: 0.85) {
uploadData = jpeg
mimeType = "image/jpeg"
} else {
uploadData = data
mimeType = "image/png"
}
let url = try await APIClient.shared.uploadAvatar(uploadData, mimeType: mimeType)
avatarURL = url
// Refresh user record so the new avatar persists across sessions
await authStore.validateToken()
} catch {
avatarError = "Upload failed: \(error.localizedDescription)"
}
}
// MARK: - Voice picker
@ViewBuilder

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"
@@ -44,7 +44,7 @@ targets:
- package: Kingfisher
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
@@ -52,9 +52,9 @@ targets:
configs:
Release:
CODE_SIGN_STYLE: Manual
CODE_SIGN_IDENTITY: "iPhone Distribution"
DEVELOPMENT_TEAM: GHZXC6FVMU
PROVISIONING_PROFILE: $(PROFILE_UUID)
CODE_SIGN_IDENTITY: "Apple Distribution"
PROVISIONING_PROFILE: "af592c3a-f60b-4ac1-a14f-30b8a206017f"
LibNovelTests:
type: bundle.unit-test
@@ -66,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

@@ -139,9 +139,9 @@ ios-archive team_id profile_uuid: ios-gen ios-resolve
-destination 'generic/platform=iOS' \
-clonedSourcePackagesDirPath {{ios_spm}} \
-archivePath {{runner_temp}}/LibNovel.xcarchive \
CODE_SIGN_STYLE=Manual \
DEVELOPMENT_TEAM="{{team_id}}" \
PROVISIONING_PROFILE="{{profile_uuid}}"
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.

View File

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

View File

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

@@ -165,6 +165,8 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("GET /api/presign/chapter/{slug}/{n}", s.handlePresignChapter)
mux.HandleFunc("GET /api/presign/audio/{slug}/{n}", s.handlePresignAudio)
mux.HandleFunc("GET /api/presign/voice-sample/{voice}", s.handlePresignVoiceSample)
mux.HandleFunc("GET /api/presign/avatar-upload/{userId}", s.handlePresignAvatarUpload)
mux.HandleFunc("GET /api/presign/avatar/{userId}", s.handlePresignAvatar)
// Plain-text chapter content (used server-side for audio generation)
mux.HandleFunc("GET /api/chapter-text/{slug}/{n}", s.handleChapterText)
// Voices list (proxied from Kokoro)

View File

@@ -316,6 +316,18 @@ func (h *HybridStore) PresignAudio(ctx context.Context, key string, expires time
return h.minio.PresignAudio(ctx, key, expires)
}
func (h *HybridStore) PresignAvatarUpload(ctx context.Context, userID, ext string) (string, string, error) {
return h.minio.PresignAvatarUploadURL(ctx, userID, ext)
}
func (h *HybridStore) PresignAvatarURL(ctx context.Context, userID string) (string, bool, error) {
return h.minio.PresignAvatarURL(ctx, userID)
}
func (h *HybridStore) DeleteAvatar(ctx context.Context, userID string) error {
return h.minio.DeleteAvatar(ctx, userID)
}
// ─── Browse page snapshots ────────────────────────────────────────────────────
func (h *HybridStore) SaveBrowsePage(ctx context.Context, key, html string) error {

View File

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

View File

@@ -458,6 +458,8 @@ type migration struct {
var migrations = []migration{
// user_id was added to progress after initial deploy.
{"progress", "user_id", "text"},
// avatar_url stores the MinIO presign path for the user's profile picture.
{"app_users", "avatar_url", "text"},
}
// EnsureMigrations idempotently adds any fields that are missing from existing

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.

View File

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

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

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

View File

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

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

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

1673
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -62,6 +62,7 @@ export interface User {
password_hash: string;
role: string;
created: string;
avatar_url?: string;
}
// ─── Auth token cache ─────────────────────────────────────────────────────────
@@ -794,3 +795,19 @@ 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}`);
}
}

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,9 +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
role: locals.user.role,
avatar_url: record?.avatar_url ?? null
});
};

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

@@ -6,6 +6,38 @@
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;
async function handleAvatarChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
avatarUploading = true;
avatarError = '';
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/profile/avatar', { method: 'POST', body: fd });
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { message?: string };
avatarError = body.message ?? `Upload failed (${res.status})`;
return;
}
const result = await res.json() as { avatar_url: string | null };
avatarUrl = result.avatar_url;
} catch {
avatarError = 'Network error during upload';
} finally {
avatarUploading = false;
if (fileInput) fileInput.value = '';
}
}
// ── Settings ────────────────────────────────────────────────────────────────
let voices = $state<string[]>([]);
let voicesLoaded = $state(false);
@@ -145,9 +177,57 @@
<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,image/gif"
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 ─────────────────────────────────────────────────── -->