Compare commits
54 Commits
3d9d8ab365
...
ios-v1.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57be674f44 | ||
|
|
93390fab64 | ||
|
|
072517135f | ||
|
|
fe7c7acbb7 | ||
|
|
d4cce915d9 | ||
|
|
ac24e86f7d | ||
|
|
e9bb387f71 | ||
|
|
d7319b3f7c | ||
|
|
f380c85815 | ||
|
|
9d1b340b83 | ||
|
|
a307ddc9f5 | ||
|
|
004d1b6d9d | ||
|
|
7f20411f50 | ||
|
|
6e6c581904 | ||
|
|
cecedc8687 | ||
|
|
a88e98a436 | ||
|
|
d3ae86d55b | ||
|
|
5ad5c2dbce | ||
|
|
0de91dcc0c | ||
|
|
8e3e9ef31d | ||
|
|
3c5edd5742 | ||
|
|
2142e82fe4 | ||
|
|
88cde88f69 | ||
|
|
ffcc3981f2 | ||
|
|
a7b4694e60 | ||
|
|
8c895c6ba1 | ||
|
|
83059c8a9d | ||
|
|
b54ebf60b5 | ||
|
|
e027afe89d | ||
|
|
9fc2054e36 | ||
|
|
9a43b2190e | ||
|
|
5a7d7ce3b9 | ||
|
|
ce3eef1298 | ||
|
|
5d9b41bcf2 | ||
|
|
47268dea67 | ||
|
|
57591766f2 | ||
|
|
fa8fb96631 | ||
|
|
5ba84f7945 | ||
|
|
2793ad8cfa | ||
|
|
e43699747d | ||
|
|
1e85f1c0bc | ||
|
|
0c2349f259 | ||
|
|
c9252b5953 | ||
|
|
7efeee3fc2 | ||
|
|
9a05708019 | ||
|
|
24cb18e0fe | ||
|
|
71ba882858 | ||
|
|
c35f099f50 | ||
|
|
4df287ace4 | ||
|
|
0df45de2b6 | ||
|
|
825fb04c0d | ||
|
|
fc5cd30c93 | ||
|
|
37bd73651a | ||
|
|
466e289b68 |
@@ -2,18 +2,20 @@ name: CI / Scraper
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ["main", "master"]
|
branches: ["main", "master", "v2"]
|
||||||
paths:
|
paths:
|
||||||
- "scraper/**"
|
- "scraper/**"
|
||||||
- "justfile"
|
|
||||||
- ".gitea/workflows/ci-scraper.yaml"
|
- ".gitea/workflows/ci-scraper.yaml"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ["main", "master"]
|
branches: ["main", "master", "v2"]
|
||||||
paths:
|
paths:
|
||||||
- "scraper/**"
|
- "scraper/**"
|
||||||
- "justfile"
|
|
||||||
- ".gitea/workflows/ci-scraper.yaml"
|
- ".gitea/workflows/ci-scraper.yaml"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── lint & vet ───────────────────────────────────────────────────────────────
|
# ── lint & vet ───────────────────────────────────────────────────────────────
|
||||||
lint:
|
lint:
|
||||||
@@ -27,11 +29,11 @@ jobs:
|
|||||||
go-version-file: scraper/go.mod
|
go-version-file: scraper/go.mod
|
||||||
cache-dependency-path: scraper/go.sum
|
cache-dependency-path: scraper/go.sum
|
||||||
|
|
||||||
- name: Install just
|
|
||||||
uses: extractions/setup-just@v2
|
|
||||||
|
|
||||||
- name: go vet
|
- name: go vet
|
||||||
run: just lint
|
working-directory: scraper
|
||||||
|
run: |
|
||||||
|
go vet ./...
|
||||||
|
go vet -tags integration ./...
|
||||||
|
|
||||||
# ── tests ────────────────────────────────────────────────────────────────────
|
# ── tests ────────────────────────────────────────────────────────────────────
|
||||||
test:
|
test:
|
||||||
@@ -45,36 +47,30 @@ jobs:
|
|||||||
go-version-file: scraper/go.mod
|
go-version-file: scraper/go.mod
|
||||||
cache-dependency-path: scraper/go.sum
|
cache-dependency-path: scraper/go.sum
|
||||||
|
|
||||||
- name: Install just
|
|
||||||
uses: extractions/setup-just@v2
|
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: just test
|
working-directory: scraper
|
||||||
|
run: go test -short -race -count=1 -timeout=60s ./...
|
||||||
|
|
||||||
# ── build binary ─────────────────────────────────────────────────────────────
|
# ── push to Docker Hub ───────────────────────────────────────────────────────
|
||||||
build:
|
docker:
|
||||||
name: Build
|
name: Docker Push
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [lint, test]
|
needs: [lint, test]
|
||||||
|
if: gitea.event_name == 'push'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
go-version-file: scraper/go.mod
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
cache-dependency-path: scraper/go.sum
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
- name: Install just
|
- name: Build and push
|
||||||
uses: extractions/setup-just@v2
|
uses: docker/build-push-action@v6
|
||||||
|
|
||||||
- name: Build binary
|
|
||||||
run: |
|
|
||||||
cd scraper && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
|
||||||
go build -ldflags="-s -w" -o bin/scraper ./cmd/scraper
|
|
||||||
|
|
||||||
- name: Upload binary artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
with:
|
||||||
name: scraper-linux-amd64
|
context: scraper
|
||||||
path: scraper/bin/scraper
|
push: true
|
||||||
retention-days: 7
|
tags: |
|
||||||
|
${{ secrets.DOCKER_USER }}/libnovel-scraper:latest
|
||||||
|
${{ secrets.DOCKER_USER }}/libnovel-scraper:${{ gitea.sha }}
|
||||||
|
|||||||
@@ -2,23 +2,28 @@ name: CI / UI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ["main", "master"]
|
branches: ["main", "master", "v2"]
|
||||||
paths:
|
paths:
|
||||||
- "ui/**"
|
- "ui/**"
|
||||||
- "justfile"
|
|
||||||
- ".gitea/workflows/ci-ui.yaml"
|
- ".gitea/workflows/ci-ui.yaml"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ["main", "master"]
|
branches: ["main", "master", "v2"]
|
||||||
paths:
|
paths:
|
||||||
- "ui/**"
|
- "ui/**"
|
||||||
- "justfile"
|
|
||||||
- ".gitea/workflows/ci-ui.yaml"
|
- ".gitea/workflows/ci-ui.yaml"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── type-check & build ───────────────────────────────────────────────────────
|
# ── type-check & build ───────────────────────────────────────────────────────
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ui
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -28,14 +33,35 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: ui/package-lock.json
|
cache-dependency-path: ui/package-lock.json
|
||||||
|
|
||||||
- name: Install just
|
|
||||||
uses: extractions/setup-just@v2
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: just ui-install
|
run: npm ci
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: just ui-check
|
run: npm run check
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: just ui-build
|
run: npm run build
|
||||||
|
|
||||||
|
# ── push to Docker Hub ───────────────────────────────────────────────────────
|
||||||
|
docker:
|
||||||
|
name: Docker Push
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
if: gitea.event_name == 'push'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ui
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ secrets.DOCKER_USER }}/libnovel-ui:latest
|
||||||
|
${{ secrets.DOCKER_USER }}/libnovel-ui:${{ gitea.sha }}
|
||||||
|
|||||||
@@ -19,14 +19,18 @@ on:
|
|||||||
# Header: x-api-key: <token>
|
# Header: x-api-key: <token>
|
||||||
# Response on success: HTTP 200, body: [{"result":{"data":{"json":{...}}}}]
|
# Response on success: HTTP 200, body: [{"result":{"data":{"json":{...}}}}]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── production deploy (main/master only) ─────────────────────────────────────
|
# ── production deploy (main/master only) ─────────────────────────────────────
|
||||||
deploy-production:
|
deploy-production:
|
||||||
name: Deploy Production
|
name: Deploy Production
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: >
|
if: >
|
||||||
github.event_name == 'push' &&
|
gitea.event_name == 'push' &&
|
||||||
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')
|
(gitea.ref == 'refs/heads/main' || gitea.ref == 'refs/heads/master')
|
||||||
steps:
|
steps:
|
||||||
- name: Redeploy production stack
|
- name: Redeploy production stack
|
||||||
run: |
|
run: |
|
||||||
@@ -46,15 +50,15 @@ jobs:
|
|||||||
name: Deploy Preview
|
name: Deploy Preview
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: >
|
if: >
|
||||||
github.event_name == 'push' &&
|
gitea.event_name == 'push' &&
|
||||||
github.ref != 'refs/heads/main' &&
|
gitea.ref != 'refs/heads/main' &&
|
||||||
github.ref != 'refs/heads/master'
|
gitea.ref != 'refs/heads/master'
|
||||||
steps:
|
steps:
|
||||||
- name: Sanitize branch name
|
- name: Sanitize branch name
|
||||||
id: branch
|
id: branch
|
||||||
run: |
|
run: |
|
||||||
# Lowercase, replace non-alphanumeric with dashes, strip trailing dashes, max 20 chars
|
# Lowercase, replace non-alphanumeric with dashes, strip trailing dashes, max 20 chars
|
||||||
SUFFIX=$(echo "${{ github.ref_name }}" \
|
SUFFIX=$(echo "${{ gitea.ref_name }}" \
|
||||||
| tr '[:upper:]' '[:lower:]' \
|
| tr '[:upper:]' '[:lower:]' \
|
||||||
| sed 's/[^a-z0-9]/-/g' \
|
| sed 's/[^a-z0-9]/-/g' \
|
||||||
| cut -c1-20 \
|
| cut -c1-20 \
|
||||||
@@ -81,12 +85,12 @@ jobs:
|
|||||||
cleanup-preview:
|
cleanup-preview:
|
||||||
name: Cleanup Preview
|
name: Cleanup Preview
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
if: gitea.event_name == 'pull_request' && gitea.event.action == 'closed'
|
||||||
steps:
|
steps:
|
||||||
- name: Sanitize branch name
|
- name: Sanitize branch name
|
||||||
id: branch
|
id: branch
|
||||||
run: |
|
run: |
|
||||||
SUFFIX=$(echo "${{ github.head_ref }}" \
|
SUFFIX=$(echo "${{ gitea.head_ref }}" \
|
||||||
| tr '[:upper:]' '[:lower:]' \
|
| tr '[:upper:]' '[:lower:]' \
|
||||||
| sed 's/[^a-z0-9]/-/g' \
|
| sed 's/[^a-z0-9]/-/g' \
|
||||||
| cut -c1-20 \
|
| cut -c1-20 \
|
||||||
96
.gitea/workflows/ios-release.yaml
Normal file
96
.gitea/workflows/ios-release.yaml
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
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: Set up Ruby with rbenv
|
||||||
|
run: |
|
||||||
|
# Install rbenv and ruby-build if not already installed
|
||||||
|
brew install rbenv ruby-build || true
|
||||||
|
|
||||||
|
# Install Ruby 3.2.2
|
||||||
|
rbenv install 3.2.2 --skip-existing
|
||||||
|
rbenv global 3.2.2
|
||||||
|
|
||||||
|
# Add rbenv to PATH for subsequent steps
|
||||||
|
echo "$(rbenv root)/shims" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
# Verify Ruby version
|
||||||
|
eval "$(rbenv init -)"
|
||||||
|
ruby --version
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: ios/LibNovel
|
||||||
|
run: |
|
||||||
|
eval "$(rbenv init -)"
|
||||||
|
gem install bundler
|
||||||
|
bundle install
|
||||||
|
|
||||||
|
- 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 $(security list-keychains -d user | tr -d '"' | xargs)
|
||||||
|
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" $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: Build and upload to TestFlight
|
||||||
|
env:
|
||||||
|
APPLE_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||||
|
APPLE_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||||
|
APPLE_KEY_CONTENT: ${{ secrets.ASC_PRIVATE_KEY }}
|
||||||
|
BUILD_NUMBER: ${{ gitea.run_number }}
|
||||||
|
PROVISIONING_PROFILE_NAME: LibNovel Distribution
|
||||||
|
working-directory: ios/LibNovel
|
||||||
|
run: |
|
||||||
|
eval "$(rbenv init -)"
|
||||||
|
bundle exec fastlane beta
|
||||||
|
|
||||||
|
- name: Cleanup keychain
|
||||||
|
if: always()
|
||||||
|
run: security delete-keychain $RUNNER_TEMP/app-signing.keychain-db
|
||||||
@@ -7,15 +7,21 @@ on:
|
|||||||
- "ios/**"
|
- "ios/**"
|
||||||
- "justfile"
|
- "justfile"
|
||||||
- ".gitea/workflows/ios.yaml"
|
- ".gitea/workflows/ios.yaml"
|
||||||
|
- ".gitea/workflows/ios-release.yaml"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ["v2", "main"]
|
branches: ["v2", "main"]
|
||||||
paths:
|
paths:
|
||||||
- "ios/**"
|
- "ios/**"
|
||||||
- "justfile"
|
- "justfile"
|
||||||
- ".gitea/workflows/ios.yaml"
|
- ".gitea/workflows/ios.yaml"
|
||||||
|
- ".gitea/workflows/ios-release.yaml"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ios-macos-runner
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── build ─────────────────────────────────────────────────────────────────
|
# ── build (simulator) ─────────────────────────────────────────────────────
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
@@ -24,15 +30,14 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install just
|
- name: Install just
|
||||||
run: brew install just
|
run: command -v just || brew install just
|
||||||
|
|
||||||
- name: Install xcodegen
|
|
||||||
run: brew install xcodegen
|
|
||||||
|
|
||||||
- name: Build (simulator)
|
- name: Build (simulator)
|
||||||
|
env:
|
||||||
|
USER: runner
|
||||||
run: just ios-build
|
run: just ios-build
|
||||||
|
|
||||||
# ── test ──────────────────────────────────────────────────────────────────
|
# ── unit tests ────────────────────────────────────────────────────────────
|
||||||
test:
|
test:
|
||||||
name: Test
|
name: Test
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
@@ -42,12 +47,11 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install just
|
- name: Install just
|
||||||
run: brew install just
|
run: command -v just || brew install just
|
||||||
|
|
||||||
- name: Install xcodegen
|
|
||||||
run: brew install xcodegen
|
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
|
env:
|
||||||
|
USER: runner
|
||||||
run: just ios-test
|
run: just ios-test
|
||||||
|
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
@@ -57,53 +61,3 @@ jobs:
|
|||||||
name: test-results
|
name: test-results
|
||||||
path: ios/LibNovel/test-results.xml
|
path: ios/LibNovel/test-results.xml
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
# ── archive (release IPA) ─────────────────────────────────────────────────
|
|
||||||
# Runs only on pushes to v2/main (not PRs).
|
|
||||||
# Requires secrets: APPLE_CERTIFICATE_BASE64, APPLE_CERTIFICATE_PASSWORD,
|
|
||||||
# APPLE_PROVISIONING_PROFILE_BASE64, KEYCHAIN_PASSWORD
|
|
||||||
# Also requires ios/LibNovel/ExportOptions.plist to exist.
|
|
||||||
#
|
|
||||||
# archive:
|
|
||||||
# name: Archive
|
|
||||||
# runs-on: macos-latest
|
|
||||||
# needs: [build, test]
|
|
||||||
# if: gitea.event_name == 'push'
|
|
||||||
#
|
|
||||||
# steps:
|
|
||||||
# - uses: actions/checkout@v4
|
|
||||||
#
|
|
||||||
# - name: Install just
|
|
||||||
# run: brew install just
|
|
||||||
#
|
|
||||||
# - name: Install xcodegen
|
|
||||||
# run: brew install xcodegen
|
|
||||||
#
|
|
||||||
# - name: Import signing certificate
|
|
||||||
# env:
|
|
||||||
# CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
|
||||||
# CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
|
||||||
# KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
|
||||||
# run: |
|
|
||||||
# KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
|
||||||
# security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
|
||||||
# security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
|
||||||
# security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
|
||||||
# echo "$CERTIFICATE_BASE64" | base64 --decode > $RUNNER_TEMP/cert.p12
|
|
||||||
# security import $RUNNER_TEMP/cert.p12 \
|
|
||||||
# -P "$CERTIFICATE_PASSWORD" \
|
|
||||||
# -A -t cert -f pkcs12 \
|
|
||||||
# -k $KEYCHAIN_PATH
|
|
||||||
# security list-keychain -d user -s $KEYCHAIN_PATH
|
|
||||||
#
|
|
||||||
# - name: Import provisioning profile
|
|
||||||
# env:
|
|
||||||
# PROFILE_BASE64: ${{ secrets.APPLE_PROVISIONING_PROFILE_BASE64 }}
|
|
||||||
# run: |
|
|
||||||
# PP_PATH=$RUNNER_TEMP/profile.mobileprovision
|
|
||||||
# echo "$PROFILE_BASE64" | base64 --decode > $PP_PATH
|
|
||||||
# mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
|
||||||
# cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/
|
|
||||||
#
|
|
||||||
# - name: Archive & export IPA
|
|
||||||
# run: just ios-archive && just ios-export
|
|
||||||
|
|||||||
@@ -5,15 +5,18 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
|
|
||||||
defaults:
|
concurrency:
|
||||||
run:
|
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||||
working-directory: ui
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── type-check & build ───────────────────────────────────────────────────────
|
# ── type-check & build ───────────────────────────────────────────────────────
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ui
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -37,33 +40,29 @@ jobs:
|
|||||||
name: Docker
|
name: Docker
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [build]
|
needs: [build]
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: .
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Log in to Gitea registry
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: gitea.kalekber.cc
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: gitea.kalekber.cc/kamil/libnovel-ui
|
images: ${{ secrets.DOCKER_USER }}/libnovel-ui
|
||||||
tags: |
|
tags: |
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=raw,value=latest
|
type=raw,value=latest
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ./ui
|
context: ui
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|||||||
10
ios/LibNovel/.gitignore
vendored
Normal file
10
ios/LibNovel/.gitignore
vendored
Normal 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
|
||||||
21
ios/LibNovel/ExportOptions.plist
Normal file
21
ios/LibNovel/ExportOptions.plist
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>method</key>
|
||||||
|
<string>app-store-connect</string>
|
||||||
|
<key>teamID</key>
|
||||||
|
<string>$(DEVELOPMENT_TEAM)</string>
|
||||||
|
<key>uploadBitcode</key>
|
||||||
|
<false/>
|
||||||
|
<key>uploadSymbols</key>
|
||||||
|
<true/>
|
||||||
|
<key>signingStyle</key>
|
||||||
|
<string>manual</string>
|
||||||
|
<key>provisioningProfiles</key>
|
||||||
|
<dict>
|
||||||
|
<key>com.kalekber.LibNovel</key>
|
||||||
|
<string>LibNovel Distribution</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
3
ios/LibNovel/Gemfile
Normal file
3
ios/LibNovel/Gemfile
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
source "https://rubygems.org"
|
||||||
|
|
||||||
|
gem "fastlane"
|
||||||
@@ -24,7 +24,6 @@
|
|||||||
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.swift */; };
|
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.swift */; };
|
||||||
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */; };
|
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */; };
|
||||||
A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB13E89E50529E3081533A66 /* AudioPlayerService.swift */; };
|
A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB13E89E50529E3081533A66 /* AudioPlayerService.swift */; };
|
||||||
BD2CA5EE70D102CA3B153485 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 6313AB4B3A5464F647791174 /* MarkdownUI */; };
|
|
||||||
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */; };
|
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */; };
|
||||||
C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB2E843D93461074A89A171 /* HomeViewModel.swift */; };
|
C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB2E843D93461074A89A171 /* HomeViewModel.swift */; };
|
||||||
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 09584EAB68A07B47F876A062 /* Kingfisher */; };
|
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 09584EAB68A07B47F876A062 /* Kingfisher */; };
|
||||||
@@ -86,7 +85,6 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */,
|
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */,
|
||||||
BD2CA5EE70D102CA3B153485 /* MarkdownUI in Frameworks */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -327,7 +325,6 @@
|
|||||||
name = LibNovel;
|
name = LibNovel;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
09584EAB68A07B47F876A062 /* Kingfisher */,
|
09584EAB68A07B47F876A062 /* Kingfisher */,
|
||||||
6313AB4B3A5464F647791174 /* MarkdownUI */,
|
|
||||||
);
|
);
|
||||||
productName = LibNovel;
|
productName = LibNovel;
|
||||||
productReference = 1B8BF3DB582A658386E402C7 /* LibNovel.app */;
|
productReference = 1B8BF3DB582A658386E402C7 /* LibNovel.app */;
|
||||||
@@ -353,7 +350,6 @@
|
|||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
||||||
C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
|
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 6318D3C6F0DC6C8E2C377103 /* Products */;
|
productRefGroup = 6318D3C6F0DC6C8E2C377103 /* Products */;
|
||||||
@@ -670,14 +666,6 @@
|
|||||||
minimumVersion = 8.0.0;
|
minimumVersion = 8.0.0;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = {
|
|
||||||
isa = XCRemoteSwiftPackageReference;
|
|
||||||
repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui";
|
|
||||||
requirement = {
|
|
||||||
kind = upToNextMajorVersion;
|
|
||||||
minimumVersion = 2.4.0;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
@@ -686,11 +674,6 @@
|
|||||||
package = AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
package = AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
||||||
productName = Kingfisher;
|
productName = Kingfisher;
|
||||||
};
|
};
|
||||||
6313AB4B3A5464F647791174 /* MarkdownUI */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
|
|
||||||
productName = MarkdownUI;
|
|
||||||
};
|
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = A10A669C0C8B43078C0FEE9F /* Project object */;
|
rootObject = A10A669C0C8B43078C0FEE9F /* Project object */;
|
||||||
|
|||||||
@@ -9,33 +9,6 @@
|
|||||||
"revision" : "c92b84898e34ab46ff0dad86c02a0acbe2d87008",
|
"revision" : "c92b84898e34ab46ff0dad86c02a0acbe2d87008",
|
||||||
"version" : "8.8.0"
|
"version" : "8.8.0"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "networkimage",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/gonzalezreal/NetworkImage",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
|
|
||||||
"version" : "6.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-cmark",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/swiftlang/swift-cmark",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
|
|
||||||
"version" : "0.7.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-markdown-ui",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
|
|
||||||
"version" : "2.4.1"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 3
|
"version" : 3
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ struct RootTabView: View {
|
|||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .bottom) {
|
||||||
TabView(selection: $selectedTab) {
|
TabView(selection: $selectedTab) {
|
||||||
HomeView()
|
HomeView()
|
||||||
.tabItem { Label("Home", systemImage: "house") }
|
.tabItem { Label("Home", systemImage: "house.fill") }
|
||||||
.tag(Tab.home)
|
.tag(Tab.home)
|
||||||
|
|
||||||
LibraryView()
|
LibraryView()
|
||||||
.tabItem { Label("Library", systemImage: "books.vertical") }
|
.tabItem { Label("Library", systemImage: "book.pages.fill") }
|
||||||
.tag(Tab.library)
|
.tag(Tab.library)
|
||||||
|
|
||||||
BrowseView()
|
BrowseView()
|
||||||
@@ -35,7 +35,7 @@ struct RootTabView: View {
|
|||||||
.tag(Tab.browse)
|
.tag(Tab.browse)
|
||||||
|
|
||||||
ProfileView()
|
ProfileView()
|
||||||
.tabItem { Label("Profile", systemImage: "person.crop.circle") }
|
.tabItem { Label("Profile", systemImage: "gear") }
|
||||||
.tag(Tab.profile)
|
.tag(Tab.profile)
|
||||||
}
|
}
|
||||||
// Reserve space for the mini-player above the tab bar so scroll content
|
// Reserve space for the mini-player above the tab bar so scroll content
|
||||||
@@ -49,7 +49,7 @@ struct RootTabView: View {
|
|||||||
// Mini-player pinned above the tab bar (hidden while full player is open)
|
// Mini-player pinned above the tab bar (hidden while full player is open)
|
||||||
if audioPlayer.isActive && !showFullPlayer {
|
if audioPlayer.isActive && !showFullPlayer {
|
||||||
MiniPlayerView(showFullPlayer: $showFullPlayer)
|
MiniPlayerView(showFullPlayer: $showFullPlayer)
|
||||||
.padding(.bottom, max(tabBarHeight - 6, 0))
|
.padding(.bottom, tabBarHeight)
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive)
|
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import SwiftUI
|
|||||||
// MARK: - App accent color (amber — mirrors Tailwind amber-500 #f59e0b)
|
// MARK: - App accent color (amber — mirrors Tailwind amber-500 #f59e0b)
|
||||||
extension Color {
|
extension Color {
|
||||||
static let amber = Color(red: 0.96, green: 0.62, blue: 0.04)
|
static let amber = Color(red: 0.96, green: 0.62, blue: 0.04)
|
||||||
static let amberLight = Color(red: 1.0, green: 0.84, blue: 0.40)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ShapeStyle where Self == Color {
|
extension ShapeStyle where Self == Color {
|
||||||
|
|||||||
@@ -6,3 +6,31 @@ enum NavDestination: Hashable {
|
|||||||
case book(String) // slug
|
case book(String) // slug
|
||||||
case chapter(String, Int) // slug + chapter number
|
case chapter(String, Int) // slug + chapter number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - View extensions for shared navigation + error alert patterns
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Registers the app-wide navigation destinations for NavDestination values.
|
||||||
|
/// Apply once per NavigationStack instead of repeating the switch in every tab.
|
||||||
|
func appNavigationDestination() -> some View {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Presents a standard "Error" alert driven by an optional String binding.
|
||||||
|
/// Dismissing the alert sets the binding back to nil.
|
||||||
|
func errorAlert(_ error: Binding<String?>) -> some View {
|
||||||
|
alert("Error", isPresented: Binding(
|
||||||
|
get: { error.wrappedValue != nil },
|
||||||
|
set: { if !$0 { error.wrappedValue = nil } }
|
||||||
|
)) {
|
||||||
|
Button("OK") { error.wrappedValue = nil }
|
||||||
|
} message: {
|
||||||
|
Text(error.wrappedValue ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,18 +3,28 @@ import Foundation
|
|||||||
// MARK: - String helpers for display purposes
|
// MARK: - String helpers for display purposes
|
||||||
|
|
||||||
extension String {
|
extension String {
|
||||||
/// Strips trailing relative-date suffixes (e.g. "2 years ago", "3 days ago")
|
/// Strips trailing relative-date suffixes (e.g. "2 years ago", "3 days ago",
|
||||||
/// that may be embedded in chapter titles coming from older scraped records.
|
/// or "(One)4 years ago" where the number is attached without a preceding space).
|
||||||
func strippingTrailingDate() -> String {
|
func strippingTrailingDate() -> String {
|
||||||
let units = ["second", "minute", "hour", "day", "week", "month", "year"]
|
let units = ["second", "minute", "hour", "day", "week", "month", "year"]
|
||||||
let lower = self.lowercased()
|
let lower = self.lowercased()
|
||||||
for unit in units {
|
for unit in units {
|
||||||
for suffix in [unit + "s ago", unit + " ago"] {
|
for suffix in [unit + "s ago", unit + " ago"] {
|
||||||
guard let suffixRange = lower.range(of: suffix, options: .backwards) else { continue }
|
guard let suffixRange = lower.range(of: suffix, options: .backwards) else { continue }
|
||||||
// Walk backwards past whitespace to find the numeric token
|
// Everything before the suffix
|
||||||
let before = String(self[self.startIndex ..< suffixRange.lowerBound])
|
let before = String(self[self.startIndex ..< suffixRange.lowerBound])
|
||||||
let trimmed = before.trimmingCharacters(in: .whitespaces)
|
let trimmed = before.trimmingCharacters(in: .whitespaces)
|
||||||
// Find start of last word (should be a number)
|
// Strip trailing digits (the numeric count, which may be attached without a space)
|
||||||
|
var result = trimmed
|
||||||
|
while let last = result.last, last.isNumber {
|
||||||
|
result.removeLast()
|
||||||
|
}
|
||||||
|
result = result.trimmingCharacters(in: .whitespaces)
|
||||||
|
if result != trimmed {
|
||||||
|
// We actually stripped some digits — return cleaned result
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
// Fallback: number preceded by space
|
||||||
if let spaceIdx = trimmed.lastIndex(of: " ") {
|
if let spaceIdx = trimmed.lastIndex(of: " ") {
|
||||||
let potentialNum = String(trimmed[trimmed.index(after: spaceIdx)...])
|
let potentialNum = String(trimmed[trimmed.index(after: spaceIdx)...])
|
||||||
if Int(potentialNum) != nil {
|
if Int(potentialNum) != nil {
|
||||||
@@ -22,7 +32,6 @@ extension String {
|
|||||||
.trimmingCharacters(in: .whitespaces)
|
.trimmingCharacters(in: .whitespaces)
|
||||||
}
|
}
|
||||||
} else if Int(trimmed) != nil {
|
} else if Int(trimmed) != nil {
|
||||||
// The entire string is just a number + "ago"
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,6 +44,6 @@ extension String {
|
|||||||
|
|
||||||
enum AppLayout {
|
enum AppLayout {
|
||||||
/// Height of the persistent mini-player bar:
|
/// Height of the persistent mini-player bar:
|
||||||
/// 2pt progress line + 20pt vertical padding + ~44pt content row.
|
/// 12pt vertical padding (top) + 56pt cover height + 12pt vertical padding (bottom) + 12pt horizontal margin.
|
||||||
static let miniPlayerBarHeight: CGFloat = 66
|
static let miniPlayerBarHeight: CGFloat = 92
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,22 +126,6 @@ struct AppUser: Codable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Browse / Novel Listing
|
|
||||||
|
|
||||||
struct NovelListing: Identifiable, Hashable {
|
|
||||||
var id: String { slug.isEmpty ? url : slug }
|
|
||||||
let slug: String
|
|
||||||
let title: String
|
|
||||||
let cover: String
|
|
||||||
let rank: String
|
|
||||||
let rating: String
|
|
||||||
let chapters: String
|
|
||||||
let url: String
|
|
||||||
let author: String
|
|
||||||
let status: String
|
|
||||||
let genres: [String]
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Ranking
|
// MARK: - Ranking
|
||||||
|
|
||||||
struct RankingItem: Codable, Identifiable {
|
struct RankingItem: Codable, Identifiable {
|
||||||
@@ -201,17 +185,6 @@ struct UserSession: Codable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Book Detail
|
|
||||||
|
|
||||||
struct BookDetailData {
|
|
||||||
let book: Book
|
|
||||||
let chapters: [ChapterIndex]
|
|
||||||
let previewChapters: [PreviewChapter]?
|
|
||||||
let inLib: Bool
|
|
||||||
let saved: Bool
|
|
||||||
let lastChapter: Int?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PreviewChapter: Codable, Identifiable {
|
struct PreviewChapter: Codable, Identifiable {
|
||||||
var id: Int { number }
|
var id: Int { number }
|
||||||
let number: Int
|
let number: Int
|
||||||
@@ -219,19 +192,6 @@ struct PreviewChapter: Codable, Identifiable {
|
|||||||
let url: String
|
let url: String
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Chapter Content
|
|
||||||
|
|
||||||
struct ChapterContent {
|
|
||||||
let book: BookBrief
|
|
||||||
let chapter: ChapterIndex
|
|
||||||
let html: String
|
|
||||||
let voices: [String]
|
|
||||||
let prev: Int?
|
|
||||||
let next: Int?
|
|
||||||
let chapters: [ChapterIndexBrief]
|
|
||||||
let isPreview: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BookBrief: Codable {
|
struct BookBrief: Codable {
|
||||||
let slug: String
|
let slug: String
|
||||||
let title: String
|
let title: String
|
||||||
|
|||||||
@@ -63,9 +63,7 @@ actor APIClient {
|
|||||||
// paths like /api/chapter/slug/1. URL(string:) preserves slashes correctly.
|
// paths like /api/chapter/slug/1. URL(string:) preserves slashes correctly.
|
||||||
let urlString = baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
let urlString = baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
+ "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
+ "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
print("[APIClient] ▶ \(method) \(urlString)")
|
|
||||||
guard let url = URL(string: urlString) else {
|
guard let url = URL(string: urlString) else {
|
||||||
print("[APIClient] ✗ Could not construct URL from: \(urlString)")
|
|
||||||
throw APIError.invalidResponse
|
throw APIError.invalidResponse
|
||||||
}
|
}
|
||||||
var req = URLRequest(url: url)
|
var req = URLRequest(url: url)
|
||||||
@@ -75,12 +73,6 @@ actor APIClient {
|
|||||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
req.httpBody = try JSONEncoder().encode(body)
|
req.httpBody = try JSONEncoder().encode(body)
|
||||||
}
|
}
|
||||||
// Log cookies being sent
|
|
||||||
if let cookies = HTTPCookieStorage.shared.cookies(for: url), !cookies.isEmpty {
|
|
||||||
print("[APIClient] cookies: \(cookies.map { "\($0.name)=\($0.value.prefix(20))…" })")
|
|
||||||
} else {
|
|
||||||
print("[APIClient] cookies: (none)")
|
|
||||||
}
|
|
||||||
return req
|
return req
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,40 +82,15 @@ actor APIClient {
|
|||||||
let req = try makeRequest(path, method: method, body: body)
|
let req = try makeRequest(path, method: method, body: body)
|
||||||
let (data, response) = try await session.data(for: req)
|
let (data, response) = try await session.data(for: req)
|
||||||
guard let http = response as? HTTPURLResponse else {
|
guard let http = response as? HTTPURLResponse else {
|
||||||
print("[APIClient] ✗ \(path) — not an HTTP response")
|
|
||||||
throw APIError.invalidResponse
|
throw APIError.invalidResponse
|
||||||
}
|
}
|
||||||
let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8 data, \(data.count) bytes>"
|
let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8 data, \(data.count) bytes>"
|
||||||
print("[APIClient] ◀ \(http.statusCode) \(path) — \(data.count) bytes")
|
|
||||||
if data.count < 2000 {
|
|
||||||
print("[APIClient] body: \(rawBody)")
|
|
||||||
} else {
|
|
||||||
print("[APIClient] body (first 2000 chars): \(rawBody.prefix(2000))")
|
|
||||||
}
|
|
||||||
guard (200..<300).contains(http.statusCode) else {
|
guard (200..<300).contains(http.statusCode) else {
|
||||||
throw APIError.httpError(http.statusCode, rawBody)
|
throw APIError.httpError(http.statusCode, rawBody)
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
let decoded = try JSONDecoder.iso8601.decode(T.self, from: data)
|
return try JSONDecoder.iso8601.decode(T.self, from: data)
|
||||||
print("[APIClient] ✓ decoded \(T.self) from \(path)")
|
|
||||||
return decoded
|
|
||||||
} catch {
|
} catch {
|
||||||
print("[APIClient] ✗ decode error for \(T.self) from \(path)")
|
|
||||||
print("[APIClient] decode error: \(error)")
|
|
||||||
if let decodingError = error as? DecodingError {
|
|
||||||
switch decodingError {
|
|
||||||
case .typeMismatch(let type, let ctx):
|
|
||||||
print("[APIClient] typeMismatch: expected \(type) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
|
|
||||||
case .valueNotFound(let type, let ctx):
|
|
||||||
print("[APIClient] valueNotFound: \(type) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
|
|
||||||
case .keyNotFound(let key, let ctx):
|
|
||||||
print("[APIClient] keyNotFound: '\(key.stringValue)' at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
|
|
||||||
case .dataCorrupted(let ctx):
|
|
||||||
print("[APIClient] dataCorrupted: \(ctx.debugDescription) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
|
|
||||||
@unknown default:
|
|
||||||
print("[APIClient] unknown decoding error: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw APIError.decodingError(error)
|
throw APIError.decodingError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{
|
{
|
||||||
"color": {
|
"color": {
|
||||||
"color-space": "srgb",
|
"color-space": "srgb",
|
||||||
"components": { "alpha": "1.000", "blue": "0.588", "green": "0.467", "red": "1.000" }
|
"components": { "alpha": "1.000", "blue": "0.040", "green": "0.620", "red": "0.960" }
|
||||||
},
|
},
|
||||||
"idiom": "universal"
|
"idiom": "universal"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 6.6 KiB |
@@ -3,6 +3,19 @@ import AVFoundation
|
|||||||
import MediaPlayer
|
import MediaPlayer
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - PlaybackProgress
|
||||||
|
// Isolated ObservableObject for high-frequency playback state (currentTime,
|
||||||
|
// duration, isPlaying). Keeping these separate from AudioPlayerService means
|
||||||
|
// the 0.5-second time-observer ticks only invalidate views that explicitly
|
||||||
|
// observe PlaybackProgress — menus and other stable UI are unaffected.
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PlaybackProgress: ObservableObject {
|
||||||
|
@Published var currentTime: Double = 0
|
||||||
|
@Published var duration: Double = 0
|
||||||
|
@Published var isPlaying: Bool = false
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - AudioPlayerService
|
// MARK: - AudioPlayerService
|
||||||
// Central singleton that owns AVPlayer, drives audio state, handles lock-screen
|
// Central singleton that owns AVPlayer, drives audio state, handles lock-screen
|
||||||
// controls (NowPlayingInfoCenter + MPRemoteCommandCenter), and pre-fetches the
|
// controls (NowPlayingInfoCenter + MPRemoteCommandCenter), and pre-fetches the
|
||||||
@@ -27,12 +40,30 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
@Published var errorMessage: String = ""
|
@Published var errorMessage: String = ""
|
||||||
@Published var generationProgress: Double = 0
|
@Published var generationProgress: Double = 0
|
||||||
|
|
||||||
@Published var currentTime: Double = 0
|
/// High-frequency playback state (currentTime / duration / isPlaying).
|
||||||
@Published var duration: Double = 0
|
/// Views that only need the seek bar or play-pause button should observe
|
||||||
@Published var isPlaying: Bool = false
|
/// this directly so they don't trigger re-renders of menu-bearing parents.
|
||||||
|
let progress = PlaybackProgress()
|
||||||
|
|
||||||
|
// Convenience forwarders so non-view call sites keep compiling unchanged.
|
||||||
|
var currentTime: Double {
|
||||||
|
get { progress.currentTime }
|
||||||
|
set { progress.currentTime = newValue }
|
||||||
|
}
|
||||||
|
var duration: Double {
|
||||||
|
get { progress.duration }
|
||||||
|
set { progress.duration = newValue }
|
||||||
|
}
|
||||||
|
var isPlaying: Bool {
|
||||||
|
get { progress.isPlaying }
|
||||||
|
set { progress.isPlaying = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
@Published var autoNext: Bool = false
|
@Published var autoNext: Bool = false
|
||||||
@Published var nextChapter: Int? = nil
|
@Published var nextChapter: Int? = nil
|
||||||
|
@Published var prevChapter: Int? = nil
|
||||||
|
|
||||||
|
@Published var sleepTimer: SleepTimerOption? = nil
|
||||||
|
|
||||||
@Published var nextPrefetchStatus: NextPrefetchStatus = .none
|
@Published var nextPrefetchStatus: NextPrefetchStatus = .none
|
||||||
@Published var nextAudioURL: String = ""
|
@Published var nextAudioURL: String = ""
|
||||||
@@ -44,6 +75,20 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
default: return true
|
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
|
// MARK: - Private
|
||||||
|
|
||||||
@@ -60,6 +105,10 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
// updateNowPlaying() call so we don't re-download on every play/pause/seek.
|
// updateNowPlaying() call so we don't re-download on every play/pause/seek.
|
||||||
private var cachedCoverArtwork: MPMediaItemArtwork?
|
private var cachedCoverArtwork: MPMediaItemArtwork?
|
||||||
private var cachedCoverURL: String = ""
|
private var cachedCoverURL: String = ""
|
||||||
|
|
||||||
|
// Sleep timer tracking
|
||||||
|
private var sleepTimerTask: Task<Void, Never>?
|
||||||
|
private var sleepTimerStartChapter: Int = 0
|
||||||
|
|
||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
|
|
||||||
@@ -73,7 +122,7 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
/// Load audio for a specific chapter. Triggers TTS generation if not cached.
|
/// Load audio for a specific chapter. Triggers TTS generation if not cached.
|
||||||
func load(slug: String, chapter: Int, chapterTitle: String,
|
func load(slug: String, chapter: Int, chapterTitle: String,
|
||||||
bookTitle: String, coverURL: String, voice: String, speed: Double,
|
bookTitle: String, coverURL: String, voice: String, speed: Double,
|
||||||
chapters: [ChapterIndexBrief], nextChapter: Int?) {
|
chapters: [ChapterIndexBrief], nextChapter: Int?, prevChapter: Int?) {
|
||||||
generationTask?.cancel()
|
generationTask?.cancel()
|
||||||
prefetchTask?.cancel()
|
prefetchTask?.cancel()
|
||||||
stop()
|
stop()
|
||||||
@@ -87,9 +136,15 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
self.speed = speed
|
self.speed = speed
|
||||||
self.chapters = chapters
|
self.chapters = chapters
|
||||||
self.nextChapter = nextChapter
|
self.nextChapter = nextChapter
|
||||||
|
self.prevChapter = prevChapter
|
||||||
self.nextPrefetchStatus = .none
|
self.nextPrefetchStatus = .none
|
||||||
self.nextAudioURL = ""
|
self.nextAudioURL = ""
|
||||||
self.nextPrefetchedChapter = nil
|
self.nextPrefetchedChapter = nil
|
||||||
|
|
||||||
|
// Reset sleep timer start chapter if it's a chapter-based timer
|
||||||
|
if case .chapters = sleepTimer {
|
||||||
|
sleepTimerStartChapter = chapter
|
||||||
|
}
|
||||||
|
|
||||||
status = .generating
|
status = .generating
|
||||||
generationProgress = 0
|
generationProgress = 0
|
||||||
@@ -139,6 +194,33 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
if isPlaying { player?.rate = Float(newSpeed) }
|
if isPlaying { player?.rate = Float(newSpeed) }
|
||||||
updateNowPlaying()
|
updateNowPlaying()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setSleepTimer(_ option: SleepTimerOption?) {
|
||||||
|
// Cancel existing timer
|
||||||
|
sleepTimerTask?.cancel()
|
||||||
|
sleepTimerTask = nil
|
||||||
|
|
||||||
|
sleepTimer = option
|
||||||
|
|
||||||
|
guard let option else { return }
|
||||||
|
|
||||||
|
// Start timer based on option
|
||||||
|
switch option {
|
||||||
|
case .chapters(let count):
|
||||||
|
sleepTimerStartChapter = chapter
|
||||||
|
// Monitor chapter changes in handlePlaybackFinished
|
||||||
|
|
||||||
|
case .minutes(let minutes):
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
player?.pause()
|
player?.pause()
|
||||||
@@ -148,6 +230,11 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
duration = 0
|
duration = 0
|
||||||
audioURL = ""
|
audioURL = ""
|
||||||
status = .idle
|
status = .idle
|
||||||
|
|
||||||
|
// Cancel sleep timer
|
||||||
|
sleepTimerTask?.cancel()
|
||||||
|
sleepTimerTask = nil
|
||||||
|
sleepTimer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Audio generation
|
// MARK: - Audio generation
|
||||||
@@ -313,6 +400,15 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
isPlaying = false
|
isPlaying = false
|
||||||
|
|
||||||
guard let next = nextChapter else { return }
|
guard let next = nextChapter else { return }
|
||||||
|
|
||||||
|
// Check chapter-based sleep timer
|
||||||
|
if case .chapters(let count) = sleepTimer {
|
||||||
|
let chaptersPlayed = chapter - sleepTimerStartChapter + 1
|
||||||
|
if chaptersPlayed >= count {
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Always notify the view that the chapter finished (it may update UI).
|
// Always notify the view that the chapter finished (it may update UI).
|
||||||
NotificationCenter.default.post(
|
NotificationCenter.default.post(
|
||||||
@@ -328,6 +424,7 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
|
|
||||||
let nextTitle = chapters.first(where: { $0.number == next })?.title ?? ""
|
let nextTitle = chapters.first(where: { $0.number == next })?.title ?? ""
|
||||||
let nextNextChapter = chapters.first(where: { $0.number > next })?.number
|
let nextNextChapter = chapters.first(where: { $0.number > next })?.number
|
||||||
|
let nextPrevChapter: Int? = chapter // Current chapter becomes previous for the next one
|
||||||
|
|
||||||
// If we already prefetched a URL for the next chapter, skip straight to
|
// If we already prefetched a URL for the next chapter, skip straight to
|
||||||
// playback and kick off generation in the background for the one after.
|
// playback and kick off generation in the background for the one after.
|
||||||
@@ -338,12 +435,18 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
chapter = next
|
chapter = next
|
||||||
chapterTitle = nextTitle
|
chapterTitle = nextTitle
|
||||||
nextChapter = nextNextChapter
|
nextChapter = nextNextChapter
|
||||||
|
prevChapter = nextPrevChapter
|
||||||
nextPrefetchStatus = .none
|
nextPrefetchStatus = .none
|
||||||
nextAudioURL = ""
|
nextAudioURL = ""
|
||||||
nextPrefetchedChapter = nil
|
nextPrefetchedChapter = nil
|
||||||
audioURL = url
|
audioURL = url
|
||||||
status = .ready
|
status = .ready
|
||||||
generationProgress = 100
|
generationProgress = 100
|
||||||
|
|
||||||
|
// Update sleep timer start chapter if using chapter-based timer
|
||||||
|
if case .chapters = sleepTimer {
|
||||||
|
sleepTimerStartChapter = next
|
||||||
|
}
|
||||||
|
|
||||||
generationTask = Task {
|
generationTask = Task {
|
||||||
await playURL(url)
|
await playURL(url)
|
||||||
@@ -360,7 +463,8 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
voice: voice,
|
voice: voice,
|
||||||
speed: speed,
|
speed: speed,
|
||||||
chapters: chapters,
|
chapters: chapters,
|
||||||
nextChapter: nextNextChapter
|
nextChapter: nextNextChapter,
|
||||||
|
prevChapter: nextPrevChapter
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -406,9 +510,9 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
self?.togglePlayPause()
|
self?.togglePlayPause()
|
||||||
return .success
|
return .success
|
||||||
}
|
}
|
||||||
center.skipForwardCommand.preferredIntervals = [30]
|
center.skipForwardCommand.preferredIntervals = [15]
|
||||||
center.skipForwardCommand.addTarget { [weak self] _ in
|
center.skipForwardCommand.addTarget { [weak self] _ in
|
||||||
self?.skip(by: 30)
|
self?.skip(by: 15)
|
||||||
return .success
|
return .success
|
||||||
}
|
}
|
||||||
center.skipBackwardCommand.preferredIntervals = [15]
|
center.skipBackwardCommand.preferredIntervals = [15]
|
||||||
@@ -460,6 +564,13 @@ enum AudioPlayerStatus: Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SleepTimerOption: Equatable {
|
||||||
|
case chapters(Int) // Stop after N chapters
|
||||||
|
case minutes(Int) // Stop after N minutes
|
||||||
|
}
|
||||||
|
|
||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
static let audioDidFinishChapter = Notification.Name("audioDidFinishChapter")
|
static let audioDidFinishChapter = Notification.Name("audioDidFinishChapter")
|
||||||
|
static let skipToNextChapter = Notification.Name("skipToNextChapter")
|
||||||
|
static let skipToPrevChapter = Notification.Name("skipToPrevChapter")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,14 +50,12 @@ final class BrowseViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadPage(_ page: Int) async {
|
private func loadPage(_ page: Int) async {
|
||||||
print("[Browse] loadPage(\(page)) genre=\(genre) sort=\(sort) status=\(status)")
|
|
||||||
isLoading = true
|
isLoading = true
|
||||||
error = nil
|
error = nil
|
||||||
do {
|
do {
|
||||||
let result = try await APIClient.shared.browse(
|
let result = try await APIClient.shared.browse(
|
||||||
page: page, genre: genre, sort: sort, status: status
|
page: page, genre: genre, sort: sort, status: status
|
||||||
)
|
)
|
||||||
print("[Browse] ✓ page \(page) — \(result.novels.count) novels, hasNext=\(result.hasNext)")
|
|
||||||
if page == 1 {
|
if page == 1 {
|
||||||
novels = result.novels
|
novels = result.novels
|
||||||
} else {
|
} else {
|
||||||
@@ -67,7 +65,6 @@ final class BrowseViewModel: ObservableObject {
|
|||||||
currentPage = page
|
currentPage = page
|
||||||
} catch {
|
} catch {
|
||||||
if !(error is CancellationError) {
|
if !(error is CancellationError) {
|
||||||
print("[Browse] ✗ error on page \(page): \(error)")
|
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,34 +3,38 @@ import Foundation
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class ChapterReaderViewModel: ObservableObject {
|
final class ChapterReaderViewModel: ObservableObject {
|
||||||
let slug: String
|
let slug: String
|
||||||
let chapter: Int
|
private(set) var chapter: Int
|
||||||
|
|
||||||
@Published var content: ChapterResponse?
|
@Published var content: ChapterResponse?
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var error: String?
|
@Published var error: String?
|
||||||
@Published var navigateTo: Int? // set to trigger navigation to next chapter
|
|
||||||
|
|
||||||
init(slug: String, chapter: Int) {
|
init(slug: String, chapter: Int) {
|
||||||
self.slug = slug
|
self.slug = slug
|
||||||
self.chapter = chapter
|
self.chapter = chapter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Switch to a different chapter in-place: resets state and updates `chapter`
|
||||||
|
/// so that `.task(id: currentChapter)` in the View re-fires `load()`.
|
||||||
|
func switchChapter(to newChapter: Int) {
|
||||||
|
guard newChapter != chapter else { return }
|
||||||
|
chapter = newChapter
|
||||||
|
content = nil
|
||||||
|
error = nil
|
||||||
|
}
|
||||||
|
|
||||||
func load() async {
|
func load() async {
|
||||||
print("[ChapterReader] load() slug=\(slug) chapter=\(chapter)")
|
|
||||||
isLoading = true
|
isLoading = true
|
||||||
error = nil
|
error = nil
|
||||||
do {
|
do {
|
||||||
content = try await APIClient.shared.chapterContent(slug: slug, chapter: chapter)
|
content = try await APIClient.shared.chapterContent(slug: slug, chapter: chapter)
|
||||||
print("[ChapterReader] ✓ loaded chapter \(chapter), html length=\(content?.html.count ?? 0)")
|
|
||||||
// Record reading progress
|
// Record reading progress
|
||||||
try? await APIClient.shared.setProgress(slug: slug, chapter: chapter)
|
try? await APIClient.shared.setProgress(slug: slug, chapter: chapter)
|
||||||
} catch {
|
} catch {
|
||||||
if !(error is CancellationError) {
|
if !(error is CancellationError) {
|
||||||
print("[ChapterReader] ✗ error: \(error)")
|
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
print("[ChapterReader] done — isLoading=false, content=\(content != nil), error=\(String(describing: self.error))")
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +51,7 @@ final class ChapterReaderViewModel: ObservableObject {
|
|||||||
audioPlayer.togglePlayPause()
|
audioPlayer.togglePlayPause()
|
||||||
} else {
|
} else {
|
||||||
let nextChapter: Int? = content.next
|
let nextChapter: Int? = content.next
|
||||||
|
let prevChapter: Int? = content.prev
|
||||||
audioPlayer.load(
|
audioPlayer.load(
|
||||||
slug: slug,
|
slug: slug,
|
||||||
chapter: chapter,
|
chapter: chapter,
|
||||||
@@ -56,7 +61,8 @@ final class ChapterReaderViewModel: ObservableObject {
|
|||||||
voice: settings.voice,
|
voice: settings.voice,
|
||||||
speed: settings.speed,
|
speed: settings.speed,
|
||||||
chapters: content.chapters,
|
chapters: content.chapters,
|
||||||
nextChapter: nextChapter
|
nextChapter: nextChapter,
|
||||||
|
prevChapter: prevChapter
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,7 @@ struct BookDetailView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar { bookmarkButton }
|
.toolbar { bookmarkButton }
|
||||||
.task { await vm.load() }
|
.task { await vm.load() }
|
||||||
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
|
.errorAlert($vm.error)
|
||||||
Button("OK") { vm.error = nil }
|
|
||||||
} message: { Text(vm.error ?? "") }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Hero
|
// MARK: - Hero
|
||||||
@@ -64,7 +62,7 @@ struct BookDetailView: View {
|
|||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.white.opacity(0.8))
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
HStack {
|
HStack {
|
||||||
TagChip(label: book.status).colorScheme(.dark)
|
// TagChip(label: book.status).colorScheme(.dark)
|
||||||
ForEach(book.genres.prefix(2), id: \.self) {
|
ForEach(book.genres.prefix(2), id: \.self) {
|
||||||
TagChip(label: $0).colorScheme(.dark)
|
TagChip(label: $0).colorScheme(.dark)
|
||||||
}
|
}
|
||||||
@@ -86,7 +84,7 @@ struct BookDetailView: View {
|
|||||||
Button(summaryExpanded ? "Less" : "More") {
|
Button(summaryExpanded ? "Less" : "More") {
|
||||||
withAnimation { summaryExpanded.toggle() }
|
withAnimation { summaryExpanded.toggle() }
|
||||||
}
|
}
|
||||||
.font(.caption.bold())
|
.font(.caption.bold())
|
||||||
.foregroundStyle(.amber)
|
.foregroundStyle(.amber)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,12 +90,7 @@ struct BrowseView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Discover")
|
.navigationTitle("Discover")
|
||||||
.navigationDestination(for: NavDestination.self) { dest in
|
.appNavigationDestination()
|
||||||
switch dest {
|
|
||||||
case .book(let slug): BookDetailView(slug: slug)
|
|
||||||
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showFilters) {
|
.sheet(isPresented: $showFilters) {
|
||||||
BrowseFiltersView(vm: vm)
|
BrowseFiltersView(vm: vm)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ struct ChapterReaderView: View {
|
|||||||
let slug: String
|
let slug: String
|
||||||
let chapterNumber: Int
|
let chapterNumber: Int
|
||||||
|
|
||||||
|
/// Tracks the currently displayed chapter — updated in-place by skip/auto-next
|
||||||
|
/// so we never accumulate stale listeners on the navigation stack.
|
||||||
|
@State private var currentChapter: Int
|
||||||
@StateObject private var vm: ChapterReaderViewModel
|
@StateObject private var vm: ChapterReaderViewModel
|
||||||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||||||
@EnvironmentObject var authStore: AuthStore
|
@EnvironmentObject var authStore: AuthStore
|
||||||
@@ -14,20 +17,17 @@ struct ChapterReaderView: View {
|
|||||||
init(slug: String, chapterNumber: Int) {
|
init(slug: String, chapterNumber: Int) {
|
||||||
self.slug = slug
|
self.slug = slug
|
||||||
self.chapterNumber = chapterNumber
|
self.chapterNumber = chapterNumber
|
||||||
|
_currentChapter = State(initialValue: chapterNumber)
|
||||||
_vm = StateObject(wrappedValue: ChapterReaderViewModel(slug: slug, chapter: chapterNumber))
|
_vm = StateObject(wrappedValue: ChapterReaderViewModel(slug: slug, chapter: chapterNumber))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let _ = print("[ChapterReaderView] body eval — slug=\(slug) ch=\(chapterNumber) isLoading=\(vm.isLoading) hasContent=\(vm.content != nil) error=\(vm.error ?? "nil")")
|
|
||||||
Group {
|
Group {
|
||||||
if vm.isLoading {
|
if vm.isLoading {
|
||||||
let _ = print("[ChapterReaderView] branch: LOADING")
|
|
||||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else if let content = vm.content {
|
} else if let content = vm.content {
|
||||||
let _ = print("[ChapterReaderView] branch: CONTENT html=\(content.html.count)chars title='\(content.chapter.title)'")
|
|
||||||
readerContent(content)
|
readerContent(content)
|
||||||
} else if let errMsg = vm.error {
|
} else if let errMsg = vm.error {
|
||||||
let _ = print("[ChapterReaderView] branch: ERROR '\(errMsg)'")
|
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Image(systemName: "exclamationmark.triangle")
|
Image(systemName: "exclamationmark.triangle")
|
||||||
.font(.largeTitle)
|
.font(.largeTitle)
|
||||||
@@ -42,33 +42,45 @@ struct ChapterReaderView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else {
|
} else {
|
||||||
let _ = print("[ChapterReaderView] branch: BLANK (no loading, no content, no error)")
|
|
||||||
Color.clear
|
Color.clear
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(vm.content.map { "Ch.\($0.chapter.number)" } ?? "")
|
.navigationTitle(vm.content.map { "Ch.\($0.chapter.number)" } ?? "")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar { audioToolbarButton }
|
.overlay(alignment: .bottomTrailing) {
|
||||||
.task {
|
// Floating audio button when player is not active
|
||||||
print("[ChapterReaderView] .task fired — calling vm.load()")
|
if !audioPlayer.isActive {
|
||||||
|
floatingAudioButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: currentChapter) {
|
||||||
await vm.load()
|
await vm.load()
|
||||||
print("[ChapterReaderView] .task completed")
|
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .audioDidFinishChapter)) { note in
|
.onReceive(NotificationCenter.default.publisher(for: .audioDidFinishChapter)) { note in
|
||||||
guard let next = note.userInfo?["next"] as? Int else { return }
|
guard let next = note.userInfo?["next"] as? Int else { return }
|
||||||
let shouldAutoNavigate = note.userInfo?["autoNext"] as? Bool ?? false
|
let shouldAutoNavigate = note.userInfo?["autoNext"] as? Bool ?? false
|
||||||
if shouldAutoNavigate {
|
// Only handle if this is the top-most (currently active) chapter view
|
||||||
vm.navigateTo = next
|
guard shouldAutoNavigate, currentChapter == audioPlayer.chapter else { return }
|
||||||
}
|
navigateToChapter(next)
|
||||||
}
|
}
|
||||||
.navigationDestination(isPresented: Binding(
|
.onReceive(NotificationCenter.default.publisher(for: .skipToNextChapter)) { note in
|
||||||
get: { vm.navigateTo != nil },
|
guard let next = note.userInfo?["next"] as? Int else { return }
|
||||||
set: { if !$0 { vm.navigateTo = nil } }
|
// Only the view whose chapter matches the currently playing chapter should handle this
|
||||||
)) {
|
guard currentChapter == audioPlayer.chapter else { return }
|
||||||
if let next = vm.navigateTo {
|
navigateToChapter(next)
|
||||||
ChapterReaderView(slug: slug, chapterNumber: next)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .skipToPrevChapter)) { note in
|
||||||
|
guard let prev = note.userInfo?["prev"] as? Int else { return }
|
||||||
|
guard currentChapter == audioPlayer.chapter else { return }
|
||||||
|
navigateToChapter(prev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigate to a chapter in-place: reloads content without pushing to the navigation stack.
|
||||||
|
/// Back button always returns to BookDetailView regardless of how many chapters were visited.
|
||||||
|
private func navigateToChapter(_ chapter: Int) {
|
||||||
|
vm.switchChapter(to: chapter)
|
||||||
|
currentChapter = chapter
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Content
|
// MARK: - Content
|
||||||
@@ -77,7 +89,6 @@ struct ChapterReaderView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func readerContent(_ content: ChapterResponse) -> some View {
|
private func readerContent(_ content: ChapterResponse) -> some View {
|
||||||
let _ = print("[ChapterReaderView] readerContent — html=\(content.html.count)chars webHeight=\(webHeight)")
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
// Header
|
// Header
|
||||||
@@ -101,17 +112,21 @@ struct ChapterReaderView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
// Prev / Next navigation
|
// Prev / Next navigation — in-place swap so back button always returns to book
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
if let prev = content.prev {
|
if let prev = content.prev {
|
||||||
NavigationLink(value: NavDestination.chapter(slug, prev)) {
|
Button {
|
||||||
|
navigateToChapter(prev)
|
||||||
|
} label: {
|
||||||
Label("Ch.\(prev)", systemImage: "chevron.left")
|
Label("Ch.\(prev)", systemImage: "chevron.left")
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
if let next = content.next {
|
if let next = content.next {
|
||||||
NavigationLink(value: NavDestination.chapter(slug, next)) {
|
Button {
|
||||||
|
navigateToChapter(next)
|
||||||
|
} label: {
|
||||||
Label("Ch.\(next)", systemImage: "chevron.right")
|
Label("Ch.\(next)", systemImage: "chevron.right")
|
||||||
.labelStyle(ReverseLabelStyle())
|
.labelStyle(ReverseLabelStyle())
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -132,21 +147,31 @@ struct ChapterReaderView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Audio toolbar button
|
// MARK: - Floating audio button
|
||||||
|
|
||||||
@ToolbarContentBuilder
|
@ViewBuilder
|
||||||
private var audioToolbarButton: some ToolbarContent {
|
private var floatingAudioButton: some View {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
Button {
|
||||||
Button {
|
vm.toggleAudio(audioPlayer: audioPlayer, settings: authStore.settings)
|
||||||
vm.toggleAudio(audioPlayer: audioPlayer, settings: authStore.settings)
|
} label: {
|
||||||
} label: {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: audioPlayer.isActive &&
|
Image(systemName: "play.circle.fill")
|
||||||
audioPlayer.slug == slug &&
|
.font(.system(size: 22))
|
||||||
audioPlayer.chapter == chapterNumber
|
Text("Listen")
|
||||||
? "speaker.wave.2.fill" : "speaker.wave.2")
|
.font(.subheadline.weight(.semibold))
|
||||||
.foregroundStyle(.amber)
|
|
||||||
}
|
}
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
Capsule()
|
||||||
|
.fill(Color.amber)
|
||||||
|
.shadow(color: .black.opacity(0.25), radius: 8, y: 4)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.trailing, 20)
|
||||||
|
.padding(.bottom, 20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +209,6 @@ struct HTMLContentView: UIViewRepresentable {
|
|||||||
p { margin: 0 0 1em 0; }
|
p { margin: 0 0 1em 0; }
|
||||||
"""
|
"""
|
||||||
let wrapped = "<html><head><style>\(css)</style><meta name='viewport' content='width=device-width, initial-scale=1'></head><body>\(html)</body></html>"
|
let wrapped = "<html><head><style>\(css)</style><meta name='viewport' content='width=device-width, initial-scale=1'></head><body>\(html)</body></html>"
|
||||||
print("[HTMLContentView] updateUIView — html=\(html.count)chars loading into WKWebView")
|
|
||||||
uiView.loadHTMLString(wrapped, baseURL: nil)
|
uiView.loadHTMLString(wrapped, baseURL: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,18 +217,12 @@ struct HTMLContentView: UIViewRepresentable {
|
|||||||
init(_ parent: HTMLContentView) { self.parent = parent }
|
init(_ parent: HTMLContentView) { self.parent = parent }
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
print("[HTMLContentView] didFinish — evaluating scrollHeight")
|
|
||||||
webView.evaluateJavaScript("document.body.scrollHeight") { result, error in
|
webView.evaluateJavaScript("document.body.scrollHeight") { result, error in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
print("[HTMLContentView] scrollHeight result=\(String(describing: result)) error=\(String(describing: error))")
|
|
||||||
if let h = result as? CGFloat, h > 0 {
|
if let h = result as? CGFloat, h > 0 {
|
||||||
print("[HTMLContentView] height set to CGFloat \(h)")
|
|
||||||
self.parent.height = h
|
self.parent.height = h
|
||||||
} else if let h = result as? Double, h > 0 {
|
} else if let h = result as? Double, h > 0 {
|
||||||
print("[HTMLContentView] height set to Double \(h)")
|
|
||||||
self.parent.height = CGFloat(h)
|
self.parent.height = CGFloat(h)
|
||||||
} else {
|
|
||||||
print("[HTMLContentView] ⚠️ could not read height — keeping \(self.parent.height)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,19 +75,10 @@ struct HomeView: View {
|
|||||||
.padding(.vertical)
|
.padding(.vertical)
|
||||||
}
|
}
|
||||||
.navigationTitle("Home")
|
.navigationTitle("Home")
|
||||||
.navigationDestination(for: NavDestination.self) { dest in
|
.appNavigationDestination()
|
||||||
switch dest {
|
|
||||||
case .book(let slug): BookDetailView(slug: slug)
|
|
||||||
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.refreshable { await vm.load() }
|
.refreshable { await vm.load() }
|
||||||
.task { await vm.load() }
|
.task { await vm.load() }
|
||||||
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
|
.errorAlert($vm.error)
|
||||||
Button("OK") { vm.error = nil }
|
|
||||||
} message: {
|
|
||||||
Text(vm.error ?? "")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,17 +34,10 @@ struct LibraryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Library")
|
.navigationTitle("Library")
|
||||||
.navigationDestination(for: NavDestination.self) { dest in
|
.appNavigationDestination()
|
||||||
switch dest {
|
|
||||||
case .book(let slug): BookDetailView(slug: slug)
|
|
||||||
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.refreshable { await vm.load() }
|
.refreshable { await vm.load() }
|
||||||
.task { await vm.load() }
|
.task { await vm.load() }
|
||||||
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
|
.errorAlert($vm.error)
|
||||||
Button("OK") { vm.error = nil }
|
|
||||||
} message: { Text(vm.error ?? "") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -68,9 +68,7 @@ struct ProfileView: View {
|
|||||||
.sheet(isPresented: $showChangePassword) {
|
.sheet(isPresented: $showChangePassword) {
|
||||||
ChangePasswordView()
|
ChangePasswordView()
|
||||||
}
|
}
|
||||||
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
|
.errorAlert($vm.error)
|
||||||
Button("OK") { vm.error = nil }
|
|
||||||
} message: { Text(vm.error ?? "") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +119,7 @@ struct ProfileView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
in: 0.5...3.0, step: 0.25
|
in: 0.5...2.0, step: 0.25
|
||||||
)
|
)
|
||||||
.tint(.amber)
|
.tint(.amber)
|
||||||
}
|
}
|
||||||
@@ -208,7 +206,8 @@ struct ChangePasswordView: View {
|
|||||||
body: Body(currentPassword: current, newPassword: newPwd)
|
body: Body(currentPassword: current, newPassword: newPwd)
|
||||||
)
|
)
|
||||||
success = true
|
success = true
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { dismiss() }
|
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||||||
|
dismiss()
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
|||||||
32
ios/LibNovel/fastlane/Fastfile
Normal file
32
ios/LibNovel/fastlane/Fastfile
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
default_platform(:ios)
|
||||||
|
|
||||||
|
platform :ios do
|
||||||
|
desc "Build and upload to TestFlight"
|
||||||
|
lane :beta do
|
||||||
|
# Generate Xcode project from project.yml
|
||||||
|
sh("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
|
||||||
|
build_app(
|
||||||
|
scheme: "LibNovel",
|
||||||
|
export_method: "app-store",
|
||||||
|
clean: true,
|
||||||
|
export_options: {
|
||||||
|
provisioningProfiles: {
|
||||||
|
"com.kalekber.LibNovel" => ENV["PROVISIONING_PROFILE_NAME"] || "LibNovel Distribution"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upload to TestFlight
|
||||||
|
upload_to_testflight(
|
||||||
|
skip_waiting_for_build_processing: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
name: LibNovel
|
name: LibNovel
|
||||||
options:
|
options:
|
||||||
bundleIdPrefix: cc.kalekber
|
bundleIdPrefix: com.kalekber
|
||||||
deploymentTarget:
|
deploymentTarget:
|
||||||
iOS: "17.0"
|
iOS: "17.0"
|
||||||
xcodeVersion: "16.0"
|
xcodeVersion: "16.0"
|
||||||
@@ -23,10 +23,6 @@ settings:
|
|||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS: ""
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS: ""
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
# Markdown rendering — renders chapter HTML from the server
|
|
||||||
MarkdownUI:
|
|
||||||
url: https://github.com/gonzalezreal/swift-markdown-ui
|
|
||||||
from: "2.4.0"
|
|
||||||
# Async image loading with caching
|
# Async image loading with caching
|
||||||
Kingfisher:
|
Kingfisher:
|
||||||
url: https://github.com/onevcat/Kingfisher
|
url: https://github.com/onevcat/Kingfisher
|
||||||
@@ -46,14 +42,17 @@ targets:
|
|||||||
- path: LibNovel/Resources/Assets.xcassets
|
- path: LibNovel/Resources/Assets.xcassets
|
||||||
dependencies:
|
dependencies:
|
||||||
- package: Kingfisher
|
- package: Kingfisher
|
||||||
- package: MarkdownUI
|
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: cc.kalekber.libnovel
|
PRODUCT_BUNDLE_IDENTIFIER: com.kalekber.LibNovel
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
TARGETED_DEVICE_FAMILY: "1,2" # iPhone + iPad
|
TARGETED_DEVICE_FAMILY: "1,2" # iPhone + iPad
|
||||||
GENERATE_INFOPLIST_FILE: NO
|
GENERATE_INFOPLIST_FILE: NO
|
||||||
INFOPLIST_FILE: LibNovel/Resources/Info.plist
|
INFOPLIST_FILE: LibNovel/Resources/Info.plist
|
||||||
|
configs:
|
||||||
|
Release:
|
||||||
|
CODE_SIGN_STYLE: Manual
|
||||||
|
DEVELOPMENT_TEAM: GHZXC6FVMU
|
||||||
|
|
||||||
LibNovelTests:
|
LibNovelTests:
|
||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
@@ -65,7 +64,7 @@ targets:
|
|||||||
- target: LibNovel
|
- target: LibNovel
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: cc.kalekber.libnovel.tests
|
PRODUCT_BUNDLE_IDENTIFIER: com.kalekber.LibNovel.tests
|
||||||
|
|
||||||
schemes:
|
schemes:
|
||||||
LibNovel:
|
LibNovel:
|
||||||
|
|||||||
26
justfile
26
justfile
@@ -130,13 +130,18 @@ ios-test: ios-gen ios-resolve
|
|||||||
# Archive a signed Release build (requires valid signing identity in keychain).
|
# Archive a signed Release build (requires valid signing identity in keychain).
|
||||||
# Output: {{runner_temp}}/LibNovel.xcarchive
|
# Output: {{runner_temp}}/LibNovel.xcarchive
|
||||||
# Typically called from CI after importing certificate + provisioning profile.
|
# Typically called from CI after importing certificate + provisioning profile.
|
||||||
ios-archive: ios-gen ios-resolve
|
# Usage: just ios-archive <team-id> <profile-uuid>
|
||||||
|
ios-archive team_id profile_uuid: ios-gen ios-resolve
|
||||||
cd {{ios_dir}} && xcodebuild archive \
|
cd {{ios_dir}} && xcodebuild archive \
|
||||||
-project {{ios_scheme}}.xcodeproj \
|
-project {{ios_scheme}}.xcodeproj \
|
||||||
-scheme {{ios_scheme}} \
|
-scheme {{ios_scheme}} \
|
||||||
-configuration Release \
|
-configuration Release \
|
||||||
|
-destination 'generic/platform=iOS' \
|
||||||
-clonedSourcePackagesDirPath {{ios_spm}} \
|
-clonedSourcePackagesDirPath {{ios_spm}} \
|
||||||
-archivePath {{runner_temp}}/LibNovel.xcarchive
|
-archivePath {{runner_temp}}/LibNovel.xcarchive \
|
||||||
|
CODE_SIGN_IDENTITY="Apple Distribution" \
|
||||||
|
"PROVISIONING_PROFILE[sdk=iphoneos*]={{profile_uuid}}" \
|
||||||
|
DEVELOPMENT_TEAM="{{team_id}}"
|
||||||
|
|
||||||
# Export an IPA from the archive produced by ios-archive.
|
# Export an IPA from the archive produced by ios-archive.
|
||||||
# Requires ios/LibNovel/ExportOptions.plist.
|
# Requires ios/LibNovel/ExportOptions.plist.
|
||||||
@@ -147,6 +152,23 @@ ios-export:
|
|||||||
-exportPath {{runner_temp}}/ipa \
|
-exportPath {{runner_temp}}/ipa \
|
||||||
-exportOptionsPlist ExportOptions.plist
|
-exportOptionsPlist ExportOptions.plist
|
||||||
|
|
||||||
|
# Set the build number (CFBundleVersion) in project.yml before archiving.
|
||||||
|
# Usage: just ios-set-build-number 42
|
||||||
|
ios-set-build-number number:
|
||||||
|
cd {{ios_dir}} && sed -i '' \
|
||||||
|
's/CURRENT_PROJECT_VERSION: .*/CURRENT_PROJECT_VERSION: {{number}}/' \
|
||||||
|
project.yml
|
||||||
|
|
||||||
|
# Upload the exported IPA to TestFlight via App Store Connect API.
|
||||||
|
# Requires env vars: ASC_KEY_ID, ASC_ISSUER_ID, ASC_PRIVATE_KEY_PATH
|
||||||
|
# The private key (.p8 file) must be present at ASC_PRIVATE_KEY_PATH.
|
||||||
|
ios-upload:
|
||||||
|
xcrun altool --upload-app \
|
||||||
|
--type ios \
|
||||||
|
--file {{runner_temp}}/ipa/LibNovel.ipa \
|
||||||
|
--apiKey "$ASC_KEY_ID" \
|
||||||
|
--apiIssuer "$ASC_ISSUER_ID"
|
||||||
|
|
||||||
# ─── Docker Compose ───────────────────────────────────────────────────────────
|
# ─── Docker Compose ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Start all services (browserless, kokoro, scraper, minio, pocketbase)
|
# Start all services (browserless, kokoro, scraper, minio, pocketbase)
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ func newE2EFixture(t *testing.T) *e2eFixture {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg)
|
hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg, slog.Default())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewHybridStore: %v", err)
|
t.Fatalf("NewHybridStore: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ func newTestStore(t *testing.T) *storage.HybridStore {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg)
|
hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg, slog.Default())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewHybridStore: %v", err)
|
t.Fatalf("NewHybridStore: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
39
scripts/runner-config-mac.yaml
Normal file
39
scripts/runner-config-mac.yaml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
log:
|
||||||
|
level: info
|
||||||
|
|
||||||
|
runner:
|
||||||
|
file: .runner
|
||||||
|
capacity: 1
|
||||||
|
envs: {}
|
||||||
|
env_file: .env
|
||||||
|
timeout: 3h
|
||||||
|
shutdown_timeout: 0s
|
||||||
|
insecure: false
|
||||||
|
fetch_timeout: 5s
|
||||||
|
fetch_interval: 2s
|
||||||
|
github_mirror: ''
|
||||||
|
labels:
|
||||||
|
- "macos-latest:host"
|
||||||
|
- "macos-14:host"
|
||||||
|
|
||||||
|
cache:
|
||||||
|
enabled: true
|
||||||
|
dir: ""
|
||||||
|
host: "__HOST_IP__"
|
||||||
|
port: 8088
|
||||||
|
external_server: ""
|
||||||
|
|
||||||
|
container:
|
||||||
|
network: ""
|
||||||
|
privileged: false
|
||||||
|
options: ""
|
||||||
|
workdir_parent: ""
|
||||||
|
valid_volumes: []
|
||||||
|
docker_host: ""
|
||||||
|
force_pull: false
|
||||||
|
force_rebuild: false
|
||||||
|
require_docker: false
|
||||||
|
docker_timeout: 0s
|
||||||
|
|
||||||
|
host:
|
||||||
|
workdir_parent: ""
|
||||||
123
scripts/setup_runner_mac.sh
Executable file
123
scripts/setup_runner_mac.sh
Executable file
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── setup_runner_mac.sh ───────────────────────────────────────────────────────
|
||||||
|
# Sets up act_runner as a host-mode runner on macOS for iOS CI/CD.
|
||||||
|
# Installs the binary, generates a config, registers against Gitea,
|
||||||
|
# and installs a LaunchDaemon so the runner starts at boot.
|
||||||
|
#
|
||||||
|
# Usage: sudo ./setup_runner_mac.sh <runner-name>
|
||||||
|
# Example: sudo ./setup_runner_mac.sh mac-runner-1
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: sudo $0 <runner-name>"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ $# -ne 1 ]] && usage
|
||||||
|
[[ "$EUID" -ne 0 ]] && { echo "ERROR: run with sudo"; exit 1; }
|
||||||
|
|
||||||
|
RUNNER_NAME="$1"
|
||||||
|
GITEA_URL="https://gitea.kalekber.cc/"
|
||||||
|
REGISTRATION_TOKEN="AboxpDKWx7gizwJ9xeheHVqKjj9J9N9BgyX96wvu"
|
||||||
|
CACHE_PORT=8088
|
||||||
|
INSTALL_DIR="/usr/local/bin"
|
||||||
|
WORK_DIR="/var/lib/act_runner"
|
||||||
|
CONFIG_PATH="/etc/act_runner/config.yaml"
|
||||||
|
LAUNCHDAEMON_PLIST="/Library/LaunchDaemons/com.gitea.act_runner.plist"
|
||||||
|
|
||||||
|
# ── detect Mac LAN IP ─────────────────────────────────────────────────────────
|
||||||
|
HOST_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "")
|
||||||
|
if [[ -z "$HOST_IP" ]]; then
|
||||||
|
echo "ERROR: could not detect LAN IP via en0/en1. Set cache.host manually in $CONFIG_PATH"
|
||||||
|
HOST_IP="127.0.0.1"
|
||||||
|
fi
|
||||||
|
echo "Host LAN IP: $HOST_IP"
|
||||||
|
|
||||||
|
# ── download act_runner binary ────────────────────────────────────────────────
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
if [[ "$ARCH" == "arm64" ]]; then
|
||||||
|
BINARY_URL="https://gitea.com/gitea/act_runner/releases/download/v0.3.0/act_runner-0.3.0-darwin-arm64"
|
||||||
|
else
|
||||||
|
BINARY_URL="https://gitea.com/gitea/act_runner/releases/download/v0.3.0/act_runner-0.3.0-darwin-amd64"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Downloading act_runner for $ARCH..."
|
||||||
|
curl -fsSL "$BINARY_URL" -o "$INSTALL_DIR/act_runner"
|
||||||
|
chmod +x "$INSTALL_DIR/act_runner"
|
||||||
|
echo "Installed: $("$INSTALL_DIR/act_runner" --version)"
|
||||||
|
|
||||||
|
# ── create working directory ──────────────────────────────────────────────────
|
||||||
|
mkdir -p "$WORK_DIR"
|
||||||
|
mkdir -p "$(dirname "$CONFIG_PATH")"
|
||||||
|
|
||||||
|
# ── install config ────────────────────────────────────────────────────────────
|
||||||
|
# Use the checked-in static config and substitute the LAN IP placeholder.
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
sed "s/__HOST_IP__/$HOST_IP/" "$SCRIPT_DIR/runner-config-mac.yaml" > "$CONFIG_PATH"
|
||||||
|
echo "Config written: labels=macos-latest:host, cache=$HOST_IP:$CACHE_PORT"
|
||||||
|
|
||||||
|
# ── register runner ───────────────────────────────────────────────────────────
|
||||||
|
echo "Registering runner '$RUNNER_NAME'..."
|
||||||
|
"$INSTALL_DIR/act_runner" register \
|
||||||
|
--no-interactive \
|
||||||
|
--config "$CONFIG_PATH" \
|
||||||
|
--instance "$GITEA_URL" \
|
||||||
|
--token "$REGISTRATION_TOKEN" \
|
||||||
|
--name "$RUNNER_NAME" \
|
||||||
|
--labels "macos-latest:host,macos-14:host"
|
||||||
|
|
||||||
|
# Copy .runner file to work dir if it was created in cwd
|
||||||
|
[[ -f ".runner" ]] && cp .runner "$WORK_DIR/.runner"
|
||||||
|
|
||||||
|
# ── install LaunchDaemon ──────────────────────────────────────────────────────
|
||||||
|
# PATH must include Homebrew + Xcode tools so xcodebuild, xcrun, npm, etc. are found.
|
||||||
|
HOMEBREW_PREFIX=$([ "$ARCH" = "arm64" ] && echo "/opt/homebrew" || echo "/usr/local")
|
||||||
|
|
||||||
|
cat > "$LAUNCHDAEMON_PLIST" <<PLIST
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.gitea.act_runner</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>${INSTALL_DIR}/act_runner</string>
|
||||||
|
<string>daemon</string>
|
||||||
|
<string>--config</string>
|
||||||
|
<string>${CONFIG_PATH}</string>
|
||||||
|
</array>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>WorkingDirectory</key>
|
||||||
|
<string>${WORK_DIR}</string>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>${WORK_DIR}/act_runner.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>${WORK_DIR}/act_runner.err</string>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>PATH</key>
|
||||||
|
<string>${HOMEBREW_PREFIX}/bin:${HOMEBREW_PREFIX}/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Applications/Xcode.app/Contents/Developer/usr/bin</string>
|
||||||
|
<key>HOME</key>
|
||||||
|
<string>${WORK_DIR}</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLIST
|
||||||
|
|
||||||
|
echo "LaunchDaemon written to $LAUNCHDAEMON_PLIST"
|
||||||
|
|
||||||
|
# ── load the daemon ───────────────────────────────────────────────────────────
|
||||||
|
launchctl unload "$LAUNCHDAEMON_PLIST" 2>/dev/null || true
|
||||||
|
launchctl load "$LAUNCHDAEMON_PLIST"
|
||||||
|
echo "Runner '$RUNNER_NAME' started via LaunchDaemon"
|
||||||
|
echo ""
|
||||||
|
echo "Useful commands:"
|
||||||
|
echo " View logs: tail -f $WORK_DIR/act_runner.log"
|
||||||
|
echo " Stop runner: sudo launchctl unload $LAUNCHDAEMON_PLIST"
|
||||||
|
echo " Start runner: sudo launchctl load $LAUNCHDAEMON_PLIST"
|
||||||
@@ -13,7 +13,6 @@ export const GET: RequestHandler = async ({ locals }) => {
|
|||||||
return json({
|
return json({
|
||||||
id: locals.user.id,
|
id: locals.user.id,
|
||||||
username: locals.user.username,
|
username: locals.user.username,
|
||||||
role: locals.user.role,
|
role: locals.user.role
|
||||||
created: locals.user.created ?? ''
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let html = $state(data.html);
|
||||||
|
let fetchingContent = $state(!data.isPreview && !data.html);
|
||||||
|
let fetchError = $state('');
|
||||||
|
|
||||||
// ── Word count ────────────────────────────────────────────────────────────
|
// ── Word count ────────────────────────────────────────────────────────────
|
||||||
function countWords(htmlStr: string | null): number {
|
function countWords(htmlStr: string | null): number {
|
||||||
if (!htmlStr) return 0;
|
if (!htmlStr) return 0;
|
||||||
@@ -15,10 +19,6 @@
|
|||||||
|
|
||||||
const wordCount = $derived(countWords(html));
|
const wordCount = $derived(countWords(html));
|
||||||
|
|
||||||
let html = $state(data.html);
|
|
||||||
let fetchingContent = $state(!data.isPreview && !data.html);
|
|
||||||
let fetchError = $state('');
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Record reading progress (skip for preview chapters)
|
// Record reading progress (skip for preview chapters)
|
||||||
if (!data.isPreview) {
|
if (!data.isPreview) {
|
||||||
|
|||||||
Reference in New Issue
Block a user