Compare commits
82 Commits
db06e3ecdc
...
v1.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
718bfa6691 | ||
|
|
e11e866e27 | ||
|
|
23345e22e6 | ||
|
|
c7b3495a23 | ||
|
|
83a5910a59 | ||
|
|
0f6639aae7 | ||
|
|
88a25bc33e | ||
|
|
73ad4ece49 | ||
|
|
52f876d8e8 | ||
|
|
72eed89f59 | ||
|
|
12bb0db5f0 | ||
|
|
5ec1773768 | ||
|
|
fb8f1dfe25 | ||
|
|
3a2d113b1b | ||
|
|
0dcfdff65b | ||
|
|
1766011b47 | ||
|
|
a6f800b0d7 | ||
|
|
af9639af05 | ||
|
|
bfc08a2df2 | ||
|
|
dc3bc3ebf2 | ||
|
|
e9d7293d37 | ||
|
|
410af8f236 | ||
|
|
264c00c765 | ||
|
|
e4c72011eb | ||
|
|
6365b14ece | ||
|
|
7da5582075 | ||
|
|
dae841e317 | ||
|
|
16b2bfffa6 | ||
|
|
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 }}
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
name: Deploy
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- "**"
|
|
||||||
paths:
|
|
||||||
- "scraper/**"
|
|
||||||
- "ui/**"
|
|
||||||
- "docker-compose.yml"
|
|
||||||
pull_request:
|
|
||||||
types: [closed]
|
|
||||||
|
|
||||||
# tRPC API helper notes:
|
|
||||||
# Mutations: POST /api/trpc/<router>.<procedure>
|
|
||||||
# Body: {"0":{"json":{...input...}}}
|
|
||||||
# Header: x-api-key: <token>
|
|
||||||
# Queries: GET /api/trpc/<router>.<procedure>?batch=1&input={"0":{"json":{...input...}}}
|
|
||||||
# Header: x-api-key: <token>
|
|
||||||
# Response on success: HTTP 200, body: [{"result":{"data":{"json":{...}}}}]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# ── production deploy (main/master only) ─────────────────────────────────────
|
|
||||||
deploy-production:
|
|
||||||
name: Deploy Production
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: >
|
|
||||||
github.event_name == 'push' &&
|
|
||||||
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')
|
|
||||||
steps:
|
|
||||||
- name: Redeploy production stack
|
|
||||||
run: |
|
|
||||||
RESPONSE=$(curl -s -w "\n%{http_code}" \
|
|
||||||
-X POST "${{ secrets.DOKPLOY_URL }}/api/trpc/compose.redeploy" \
|
|
||||||
-H "x-api-key: ${{ secrets.DOKPLOY_TOKEN }}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"0":{"json":{"composeId":"${{ secrets.DOKPLOY_COMPOSE_ID }}"}}}')
|
|
||||||
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
|
||||||
BODY=$(echo "$RESPONSE" | head -1)
|
|
||||||
echo "Status: $HTTP_CODE"
|
|
||||||
echo "Body: $BODY"
|
|
||||||
[ "$HTTP_CODE" = "200" ] || { echo "Redeploy failed"; exit 1; }
|
|
||||||
|
|
||||||
# ── preview deploy (feature branches) ────────────────────────────────────────
|
|
||||||
deploy-preview:
|
|
||||||
name: Deploy Preview
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: >
|
|
||||||
github.event_name == 'push' &&
|
|
||||||
github.ref != 'refs/heads/main' &&
|
|
||||||
github.ref != 'refs/heads/master'
|
|
||||||
steps:
|
|
||||||
- name: Sanitize branch name
|
|
||||||
id: branch
|
|
||||||
run: |
|
|
||||||
# Lowercase, replace non-alphanumeric with dashes, strip trailing dashes, max 20 chars
|
|
||||||
SUFFIX=$(echo "${{ github.ref_name }}" \
|
|
||||||
| tr '[:upper:]' '[:lower:]' \
|
|
||||||
| sed 's/[^a-z0-9]/-/g' \
|
|
||||||
| cut -c1-20 \
|
|
||||||
| sed 's/-*$//')
|
|
||||||
echo "suffix=$SUFFIX" >> $GITHUB_OUTPUT
|
|
||||||
echo "Preview suffix: $SUFFIX"
|
|
||||||
|
|
||||||
- name: Create or redeploy isolated preview stack
|
|
||||||
run: |
|
|
||||||
# compose.isolatedDeployment creates a new isolated copy of the compose stack
|
|
||||||
# suffixed with the branch name. If the stack already exists it redeploys it.
|
|
||||||
RESPONSE=$(curl -s -w "\n%{http_code}" \
|
|
||||||
-X POST "${{ secrets.DOKPLOY_URL }}/api/trpc/compose.isolatedDeployment" \
|
|
||||||
-H "x-api-key: ${{ secrets.DOKPLOY_TOKEN }}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"0\":{\"json\":{\"composeId\":\"${{ secrets.DOKPLOY_COMPOSE_ID }}\",\"suffix\":\"${{ steps.branch.outputs.suffix }}\"}}}")
|
|
||||||
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
|
||||||
BODY=$(echo "$RESPONSE" | head -1)
|
|
||||||
echo "Status: $HTTP_CODE"
|
|
||||||
echo "Body: $BODY"
|
|
||||||
[ "$HTTP_CODE" = "200" ] || { echo "Preview deploy failed"; exit 1; }
|
|
||||||
|
|
||||||
# ── cleanup preview on PR close ───────────────────────────────────────────────
|
|
||||||
cleanup-preview:
|
|
||||||
name: Cleanup Preview
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
|
||||||
steps:
|
|
||||||
- name: Sanitize branch name
|
|
||||||
id: branch
|
|
||||||
run: |
|
|
||||||
SUFFIX=$(echo "${{ github.head_ref }}" \
|
|
||||||
| tr '[:upper:]' '[:lower:]' \
|
|
||||||
| sed 's/[^a-z0-9]/-/g' \
|
|
||||||
| cut -c1-20 \
|
|
||||||
| sed 's/-*$//')
|
|
||||||
echo "suffix=$SUFFIX" >> $GITHUB_OUTPUT
|
|
||||||
echo "Cleaning up preview suffix: $SUFFIX"
|
|
||||||
|
|
||||||
- name: Search for preview compose stack by appName
|
|
||||||
id: find
|
|
||||||
run: |
|
|
||||||
# compose.search is a tRPC query (GET). We search by appName pattern.
|
|
||||||
# appName is set by Dokploy as "<base-appName>-<suffix>" for isolated deployments.
|
|
||||||
INPUT=$(python3 -c "import json,sys; print(json.dumps({'0':{'json':{'appName':'libnovel-${{ steps.branch.outputs.suffix }}','limit':5,'offset':0}}}))")
|
|
||||||
RESPONSE=$(curl -s -w "\n%{http_code}" -G \
|
|
||||||
"${{ secrets.DOKPLOY_URL }}/api/trpc/compose.search" \
|
|
||||||
-H "x-api-key: ${{ secrets.DOKPLOY_TOKEN }}" \
|
|
||||||
--data-urlencode "batch=1" \
|
|
||||||
--data-urlencode "input=$INPUT")
|
|
||||||
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
|
||||||
BODY=$(echo "$RESPONSE" | head -1)
|
|
||||||
echo "Status: $HTTP_CODE"
|
|
||||||
echo "Body: $BODY"
|
|
||||||
# Extract the first composeId from the JSON response array
|
|
||||||
COMPOSE_ID=$(echo "$BODY" | python3 -c "
|
|
||||||
import json,sys
|
|
||||||
data = json.load(sys.stdin)
|
|
||||||
items = data[0]['result']['data']['json']['items']
|
|
||||||
print(items[0]['composeId'] if items else '')
|
|
||||||
" 2>/dev/null || echo "")
|
|
||||||
echo "composeId=$COMPOSE_ID" >> $GITHUB_OUTPUT
|
|
||||||
echo "Found composeId: $COMPOSE_ID"
|
|
||||||
|
|
||||||
- name: Delete preview stack
|
|
||||||
if: steps.find.outputs.composeId != ''
|
|
||||||
run: |
|
|
||||||
RESPONSE=$(curl -s -w "\n%{http_code}" \
|
|
||||||
-X POST "${{ secrets.DOKPLOY_URL }}/api/trpc/compose.delete" \
|
|
||||||
-H "x-api-key: ${{ secrets.DOKPLOY_TOKEN }}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"0\":{\"json\":{\"composeId\":\"${{ steps.find.outputs.composeId }}\",\"deleteVolumes\":true}}}")
|
|
||||||
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
|
||||||
BODY=$(echo "$RESPONSE" | head -1)
|
|
||||||
echo "Status: $HTTP_CODE"
|
|
||||||
echo "Body: $BODY"
|
|
||||||
[ "$HTTP_CODE" = "200" ] || { echo "Delete failed"; exit 1; }
|
|
||||||
@@ -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
|
|
||||||
|
|||||||
65
.gitea/workflows/release-scraper.yaml
Normal file
65
.gitea/workflows/release-scraper.yaml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
name: Release / Scraper
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ── lint & test ──────────────────────────────────────────────────────────────
|
||||||
|
test:
|
||||||
|
name: Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: scraper/go.mod
|
||||||
|
cache-dependency-path: scraper/go.sum
|
||||||
|
|
||||||
|
- name: go vet
|
||||||
|
working-directory: scraper
|
||||||
|
run: |
|
||||||
|
go vet ./...
|
||||||
|
go vet -tags integration ./...
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
working-directory: scraper
|
||||||
|
run: go test -short -race -count=1 -timeout=60s ./...
|
||||||
|
|
||||||
|
# ── docker build & push ──────────────────────────────────────────────────────
|
||||||
|
docker:
|
||||||
|
name: Docker
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [test]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ secrets.DOCKER_USER }}/libnovel-scraper
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=raw,value=latest
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: scraper
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ services:
|
|||||||
mc mb --ignore-existing local/libnovel-chapters;
|
mc mb --ignore-existing local/libnovel-chapters;
|
||||||
mc mb --ignore-existing local/libnovel-audio;
|
mc mb --ignore-existing local/libnovel-audio;
|
||||||
mc mb --ignore-existing local/libnovel-browse;
|
mc mb --ignore-existing local/libnovel-browse;
|
||||||
|
mc mb --ignore-existing local/libnovel-avatars;
|
||||||
echo 'buckets ready';
|
echo 'buckets ready';
|
||||||
"
|
"
|
||||||
environment:
|
environment:
|
||||||
@@ -106,6 +107,7 @@ services:
|
|||||||
MINIO_BUCKET_CHAPTERS: "${MINIO_BUCKET_CHAPTERS:-libnovel-chapters}"
|
MINIO_BUCKET_CHAPTERS: "${MINIO_BUCKET_CHAPTERS:-libnovel-chapters}"
|
||||||
MINIO_BUCKET_AUDIO: "${MINIO_BUCKET_AUDIO:-libnovel-audio}"
|
MINIO_BUCKET_AUDIO: "${MINIO_BUCKET_AUDIO:-libnovel-audio}"
|
||||||
MINIO_BUCKET_BROWSE: "${MINIO_BUCKET_BROWSE:-libnovel-browse}"
|
MINIO_BUCKET_BROWSE: "${MINIO_BUCKET_BROWSE:-libnovel-browse}"
|
||||||
|
MINIO_BUCKET_AVATARS: "${MINIO_BUCKET_AVATARS:-libnovel-avatars}"
|
||||||
# Public endpoint used to sign presigned audio URLs so browsers can reach them.
|
# Public endpoint used to sign presigned audio URLs so browsers can reach them.
|
||||||
# Leave empty to use MINIO_ENDPOINT (fine for local dev).
|
# Leave empty to use MINIO_ENDPOINT (fine for local dev).
|
||||||
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-}"
|
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-}"
|
||||||
@@ -147,7 +149,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${UI_PORT:-5252}:3000"
|
- "${UI_PORT:-5252}:3000"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/"]
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
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</string>
|
||||||
|
<key>teamID</key>
|
||||||
|
<string>GHZXC6FVMU</string>
|
||||||
|
<key>uploadBitcode</key>
|
||||||
|
<false/>
|
||||||
|
<key>uploadSymbols</key>
|
||||||
|
<true/>
|
||||||
|
<key>signingStyle</key>
|
||||||
|
<string>manual</string>
|
||||||
|
<key>provisioningProfiles</key>
|
||||||
|
<dict>
|
||||||
|
<key>com.kalekber.LibNovel</key>
|
||||||
|
<string>LibNovel Distribution</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
3
ios/LibNovel/Gemfile
Normal file
3
ios/LibNovel/Gemfile
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
source "https://rubygems.org"
|
||||||
|
|
||||||
|
gem "fastlane"
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */; };
|
2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */; };
|
||||||
3521DFD5FCBBED7B90368829 /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC338B05EA6DB22900712000 /* LibraryViewModel.swift */; };
|
3521DFD5FCBBED7B90368829 /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC338B05EA6DB22900712000 /* LibraryViewModel.swift */; };
|
||||||
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5C115992F1CE2326236765 /* RootTabView.swift */; };
|
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5C115992F1CE2326236765 /* RootTabView.swift */; };
|
||||||
|
41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC6F837FF2E902E334ED72E /* String+App.swift */; };
|
||||||
4BB2C76262D5BD5DAD0D5D28 /* LibNovelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C918833E173D6B44D06955 /* LibNovelTests.swift */; };
|
4BB2C76262D5BD5DAD0D5D28 /* LibNovelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C918833E173D6B44D06955 /* LibNovelTests.swift */; };
|
||||||
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */; };
|
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */; };
|
||||||
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F219788AE5ACBD6F240674F5 /* AuthStore.swift */; };
|
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F219788AE5ACBD6F240674F5 /* AuthStore.swift */; };
|
||||||
@@ -24,13 +25,11 @@
|
|||||||
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.swift */; };
|
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 */; };
|
||||||
E1F564399D1325F6A1B2B84F /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C21107BECA55C07416E0CB8B /* LibraryView.swift */; };
|
E1F564399D1325F6A1B2B84F /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C21107BECA55C07416E0CB8B /* LibraryView.swift */; };
|
||||||
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D83BB88C4306BE7A4F947CB /* Color+App.swift */; };
|
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D83BB88C4306BE7A4F947CB /* Color+App.swift */; };
|
||||||
A1B2C3D4E5F6789012345678 /* String+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F67890123456789A /* String+App.swift */; };
|
|
||||||
EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6268D60803940CBD38FB921 /* HomeView.swift */; };
|
EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6268D60803940CBD38FB921 /* HomeView.swift */; };
|
||||||
F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E89FD8F46747CA653C5203D /* CommonViews.swift */; };
|
F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E89FD8F46747CA653C5203D /* CommonViews.swift */; };
|
||||||
F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CAFB96D2500F34F0B0C860C /* NavDestination.swift */; };
|
F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CAFB96D2500F34F0B0C860C /* NavDestination.swift */; };
|
||||||
@@ -51,7 +50,7 @@
|
|||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
1B8BF3DB582A658386E402C7 /* LibNovel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LibNovel.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
1B8BF3DB582A658386E402C7 /* LibNovel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LibNovel.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = "<group>"; };
|
1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = "<group>"; };
|
||||||
235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LibNovelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LibNovelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
2D5C115992F1CE2326236765 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = "<group>"; };
|
2D5C115992F1CE2326236765 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = "<group>"; };
|
||||||
39DE056C37FBC5EED8771821 /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = "<group>"; };
|
39DE056C37FBC5EED8771821 /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = "<group>"; };
|
||||||
3AB2E843D93461074A89A171 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
3AB2E843D93461074A89A171 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
||||||
@@ -68,7 +67,6 @@
|
|||||||
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = "<group>"; };
|
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = "<group>"; };
|
||||||
9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewModel.swift; sourceTree = "<group>"; };
|
9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewModel.swift; sourceTree = "<group>"; };
|
||||||
9D83BB88C4306BE7A4F947CB /* Color+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+App.swift"; sourceTree = "<group>"; };
|
9D83BB88C4306BE7A4F947CB /* Color+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+App.swift"; sourceTree = "<group>"; };
|
||||||
B2C3D4E5F67890123456789A /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+App.swift"; sourceTree = "<group>"; };
|
|
||||||
B4C918833E173D6B44D06955 /* LibNovelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelTests.swift; sourceTree = "<group>"; };
|
B4C918833E173D6B44D06955 /* LibNovelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelTests.swift; sourceTree = "<group>"; };
|
||||||
B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
||||||
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
|
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
|
||||||
@@ -78,6 +76,7 @@
|
|||||||
DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViews.swift; sourceTree = "<group>"; };
|
DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViews.swift; sourceTree = "<group>"; };
|
||||||
F219788AE5ACBD6F240674F5 /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = "<group>"; };
|
F219788AE5ACBD6F240674F5 /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = "<group>"; };
|
||||||
FC338B05EA6DB22900712000 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = "<group>"; };
|
FC338B05EA6DB22900712000 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
FEC6F837FF2E902E334ED72E /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+App.swift"; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
@@ -276,7 +274,7 @@
|
|||||||
children = (
|
children = (
|
||||||
9D83BB88C4306BE7A4F947CB /* Color+App.swift */,
|
9D83BB88C4306BE7A4F947CB /* Color+App.swift */,
|
||||||
7CAFB96D2500F34F0B0C860C /* NavDestination.swift */,
|
7CAFB96D2500F34F0B0C860C /* NavDestination.swift */,
|
||||||
B2C3D4E5F67890123456789A /* String+App.swift */,
|
FEC6F837FF2E902E334ED72E /* String+App.swift */,
|
||||||
);
|
);
|
||||||
path = Extensions;
|
path = Extensions;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -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 */;
|
||||||
@@ -340,7 +337,7 @@
|
|||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
LastUpgradeCheck = 1600;
|
LastUpgradeCheck = 2630;
|
||||||
};
|
};
|
||||||
buildConfigurationList = D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */;
|
buildConfigurationList = D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */;
|
||||||
developmentRegion = en;
|
developmentRegion = en;
|
||||||
@@ -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 */;
|
||||||
@@ -401,7 +397,6 @@
|
|||||||
FEFB5FDC2424D22914458001 /* ChapterReaderView.swift in Sources */,
|
FEFB5FDC2424D22914458001 /* ChapterReaderView.swift in Sources */,
|
||||||
2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */,
|
2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */,
|
||||||
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */,
|
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */,
|
||||||
A1B2C3D4E5F6789012345678 /* String+App.swift in Sources */,
|
|
||||||
F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */,
|
F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */,
|
||||||
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */,
|
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */,
|
||||||
EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */,
|
EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */,
|
||||||
@@ -415,6 +410,7 @@
|
|||||||
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */,
|
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */,
|
||||||
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */,
|
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */,
|
||||||
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */,
|
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */,
|
||||||
|
41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -439,7 +435,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel.tests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel.tests;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LibNovel.app/LibNovel";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LibNovel.app/LibNovel";
|
||||||
@@ -456,7 +452,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel.tests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel.tests;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LibNovel.app/LibNovel";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LibNovel.app/LibNovel";
|
||||||
@@ -467,16 +463,21 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = GHZXC6FVMU;
|
DEVELOPMENT_TEAM = GHZXC6FVMU;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
|
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel;
|
MARKETING_VERSION = 1.0.1;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel;
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
@@ -516,11 +517,12 @@
|
|||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1000;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
GCC_DYNAMIC_NO_PIC = NO;
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
@@ -543,6 +545,7 @@
|
|||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.10;
|
SWIFT_VERSION = 5.10;
|
||||||
@@ -553,16 +556,24 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = GHZXC6FVMU;
|
DEVELOPMENT_TEAM = GHZXC6FVMU;
|
||||||
|
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = GHZXC6FVMU;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
|
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel;
|
MARKETING_VERSION = 1.0.1;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel;
|
||||||
|
PROVISIONING_PROFILE = "af592c3a-f60b-4ac1-a14f-30b8a206017f";
|
||||||
|
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LibNovel Distribution";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
@@ -602,11 +613,12 @@
|
|||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1000;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
@@ -622,6 +634,7 @@
|
|||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
@@ -670,14 +683,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 +691,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 */;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "18350c2bfa3935125b6f4e9817e7ed4508588c07142d420b8b8ee00640a57853",
|
"originHash" : "ad75ae2d3b8d8b80d99635f65213a3c1092464aa54a86354f850b8317b6fa240",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "kingfisher",
|
"identity" : "kingfisher",
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1600"
|
LastUpgradeVersion = "2630"
|
||||||
version = "1.7">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES"
|
buildImplicitDependencies = "YES">
|
||||||
runPostActionsOnFailure = "NO">
|
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
@@ -27,8 +26,7 @@
|
|||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
|
||||||
<MacroExpansion>
|
<MacroExpansion>
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
@@ -51,8 +49,6 @@
|
|||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</TestableReference>
|
</TestableReference>
|
||||||
</Testables>
|
</Testables>
|
||||||
<CommandLineArguments>
|
|
||||||
</CommandLineArguments>
|
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
@@ -74,12 +70,10 @@
|
|||||||
ReferencedContainer = "container:LibNovel.xcodeproj">
|
ReferencedContainer = "container:LibNovel.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
<CommandLineArguments>
|
|
||||||
</CommandLineArguments>
|
|
||||||
<EnvironmentVariables>
|
<EnvironmentVariables>
|
||||||
<EnvironmentVariable
|
<EnvironmentVariable
|
||||||
key = "LIBNOVEL_BASE_URL"
|
key = "LIBNOVEL_BASE_URL"
|
||||||
value = "["isEnabled": true, "value": "https://v2.libnovel.kalekber.cc"]"
|
value = "["value": "https://v2.libnovel.kalekber.cc", "isEnabled": true]"
|
||||||
isEnabled = "YES">
|
isEnabled = "YES">
|
||||||
</EnvironmentVariable>
|
</EnvironmentVariable>
|
||||||
</EnvironmentVariables>
|
</EnvironmentVariables>
|
||||||
@@ -100,8 +94,6 @@
|
|||||||
ReferencedContainer = "container:LibNovel.xcodeproj">
|
ReferencedContainer = "container:LibNovel.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
<CommandLineArguments>
|
|
||||||
</CommandLineArguments>
|
|
||||||
</ProfileAction>
|
</ProfileAction>
|
||||||
<AnalyzeAction
|
<AnalyzeAction
|
||||||
buildConfiguration = "Debug">
|
buildConfiguration = "Debug">
|
||||||
|
|||||||
@@ -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,8 +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(.horizontal, 12)
|
.padding(.bottom, tabBarHeight)
|
||||||
.padding(.bottom, max(tabBarHeight - 2, 0))
|
|
||||||
.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,95 @@ 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 {
|
||||||
|
modifier(AppNavigationDestinationModifier())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Presents a standard "Error" alert driven by an optional String binding.
|
||||||
|
/// Dismissing the alert sets the binding back to nil.
|
||||||
|
func errorAlert(_ error: Binding<String?>) -> some View {
|
||||||
|
alert("Error", isPresented: Binding(
|
||||||
|
get: { error.wrappedValue != nil },
|
||||||
|
set: { if !$0 { error.wrappedValue = nil } }
|
||||||
|
)) {
|
||||||
|
Button("OK") { error.wrappedValue = nil }
|
||||||
|
} message: {
|
||||||
|
Text(error.wrappedValue ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Navigation destination modifier
|
||||||
|
|
||||||
|
private struct AppNavigationDestinationModifier: ViewModifier {
|
||||||
|
@Namespace private var zoomNamespace
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
if #available(iOS 18.0, *) {
|
||||||
|
content
|
||||||
|
.navigationDestination(for: NavDestination.self) { dest in
|
||||||
|
switch dest {
|
||||||
|
case .book(let slug):
|
||||||
|
BookDetailView(slug: slug)
|
||||||
|
.navigationTransition(.zoom(sourceID: slug, in: zoomNamespace))
|
||||||
|
case .chapter(let slug, let n):
|
||||||
|
ChapterReaderView(slug: slug, chapterNumber: n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Expose namespace to child views via environment
|
||||||
|
.environment(\.bookZoomNamespace, zoomNamespace)
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
.navigationDestination(for: NavDestination.self) { dest in
|
||||||
|
switch dest {
|
||||||
|
case .book(let slug): BookDetailView(slug: slug)
|
||||||
|
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Environment key for zoom namespace
|
||||||
|
|
||||||
|
struct BookZoomNamespaceKey: EnvironmentKey {
|
||||||
|
static var defaultValue: Namespace.ID? { nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnvironmentValues {
|
||||||
|
var bookZoomNamespace: Namespace.ID? {
|
||||||
|
get { self[BookZoomNamespaceKey.self] }
|
||||||
|
set { self[BookZoomNamespaceKey.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cover card zoom source modifier
|
||||||
|
|
||||||
|
/// Apply this to any cover image that should be a zoom source for book navigation.
|
||||||
|
/// Falls back to a no-op on iOS 17 or when no namespace is available.
|
||||||
|
struct BookCoverZoomSource: ViewModifier {
|
||||||
|
let slug: String
|
||||||
|
@Environment(\.bookZoomNamespace) private var namespace
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
if #available(iOS 18.0, *), let ns = namespace {
|
||||||
|
content.matchedTransitionSource(id: slug, in: ns)
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Marks a cover image as the zoom source for a book's navigation transition.
|
||||||
|
func bookCoverZoomSource(slug: String) -> some View {
|
||||||
|
modifier(BookCoverZoomSource(slug: slug))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = 56
|
static let miniPlayerBarHeight: CGFloat = 92
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - Book
|
// MARK: - Book
|
||||||
|
|
||||||
@@ -72,27 +73,6 @@ struct ChapterIndexBrief: Codable, Hashable {
|
|||||||
let title: String
|
let title: String
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Progress
|
|
||||||
|
|
||||||
struct ReadingProgress: Codable {
|
|
||||||
var id: String?
|
|
||||||
let sessionId: String
|
|
||||||
var userId: String?
|
|
||||||
let slug: String
|
|
||||||
var chapter: Int
|
|
||||||
var audioTime: Double?
|
|
||||||
let updated: String
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case id
|
|
||||||
case sessionId = "session_id"
|
|
||||||
case userId = "user_id"
|
|
||||||
case slug, chapter
|
|
||||||
case audioTime = "audio_time"
|
|
||||||
case updated
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - User Settings
|
// MARK: - User Settings
|
||||||
|
|
||||||
struct UserSettings: Codable {
|
struct UserSettings: Codable {
|
||||||
@@ -107,6 +87,81 @@ struct UserSettings: Codable {
|
|||||||
static let `default` = UserSettings(id: nil, autoNext: false, voice: "af_bella", speed: 1.0)
|
static let `default` = UserSettings(id: nil, autoNext: false, voice: "af_bella", speed: 1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Reading Display Settings (local only — stored in UserDefaults)
|
||||||
|
|
||||||
|
enum ReaderTheme: String, CaseIterable, Codable {
|
||||||
|
case white, sepia, night
|
||||||
|
|
||||||
|
var backgroundColor: Color {
|
||||||
|
switch self {
|
||||||
|
case .white: return Color(.sRGB, white: 1.0, opacity: 1)
|
||||||
|
case .sepia: return Color(red: 0.97, green: 0.93, blue: 0.82)
|
||||||
|
case .night: return Color(red: 0.10, green: 0.10, blue: 0.12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var textColor: Color {
|
||||||
|
switch self {
|
||||||
|
case .white: return Color(.sRGB, white: 0.1, opacity: 1)
|
||||||
|
case .sepia: return Color(red: 0.25, green: 0.18, blue: 0.08)
|
||||||
|
case .night: return Color(red: 0.85, green: 0.85, blue: 0.87)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var colorScheme: ColorScheme? {
|
||||||
|
switch self {
|
||||||
|
case .white: return nil // follows system
|
||||||
|
case .sepia: return .light
|
||||||
|
case .night: return .dark
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReaderFont: String, CaseIterable, Codable {
|
||||||
|
case system = "System"
|
||||||
|
case georgia = "Georgia"
|
||||||
|
case newYork = "New York"
|
||||||
|
|
||||||
|
var fontName: String? {
|
||||||
|
switch self {
|
||||||
|
case .system: return nil
|
||||||
|
case .georgia: return "Georgia"
|
||||||
|
case .newYork: return "NewYorkMedium-Regular"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ReaderSettings: Codable, Equatable {
|
||||||
|
var fontSize: CGFloat
|
||||||
|
var lineSpacing: CGFloat
|
||||||
|
var font: ReaderFont
|
||||||
|
var theme: ReaderTheme
|
||||||
|
var scrollMode: Bool
|
||||||
|
|
||||||
|
static let `default` = ReaderSettings(
|
||||||
|
fontSize: 17,
|
||||||
|
lineSpacing: 1.7,
|
||||||
|
font: .system,
|
||||||
|
theme: .white,
|
||||||
|
scrollMode: false
|
||||||
|
)
|
||||||
|
|
||||||
|
static let userDefaultsKey = "readerSettings"
|
||||||
|
|
||||||
|
static func load() -> ReaderSettings {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: userDefaultsKey),
|
||||||
|
let decoded = try? JSONDecoder().decode(ReaderSettings.self, from: data)
|
||||||
|
else { return .default }
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
func save() {
|
||||||
|
if let data = try? JSONEncoder().encode(self) {
|
||||||
|
UserDefaults.standard.set(data, forKey: ReaderSettings.userDefaultsKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - User
|
// MARK: - User
|
||||||
|
|
||||||
struct AppUser: Codable, Identifiable {
|
struct AppUser: Codable, Identifiable {
|
||||||
@@ -114,34 +169,33 @@ struct AppUser: Codable, Identifiable {
|
|||||||
let username: String
|
let username: String
|
||||||
let role: String
|
let role: String
|
||||||
let created: String
|
let created: String
|
||||||
|
let avatarURL: String?
|
||||||
|
|
||||||
var isAdmin: Bool { role == "admin" }
|
var isAdmin: Bool { role == "admin" }
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, username, role, created
|
||||||
|
case avatarURL = "avatar_url"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(id: String, username: String, role: String, created: String, avatarURL: String?) {
|
||||||
|
self.id = id
|
||||||
|
self.username = username
|
||||||
|
self.role = role
|
||||||
|
self.created = created
|
||||||
|
self.avatarURL = avatarURL
|
||||||
|
}
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
id = try c.decode(String.self, forKey: .id)
|
id = try c.decode(String.self, forKey: .id)
|
||||||
username = try c.decode(String.self, forKey: .username)
|
username = try c.decode(String.self, forKey: .username)
|
||||||
role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user"
|
role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user"
|
||||||
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
|
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
|
||||||
|
avatarURL = try c.decodeIfPresent(String.self, forKey: .avatarURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Browse / Novel Listing
|
|
||||||
|
|
||||||
struct NovelListing: Identifiable, Hashable {
|
|
||||||
var id: String { slug.isEmpty ? url : slug }
|
|
||||||
let slug: String
|
|
||||||
let title: String
|
|
||||||
let cover: String
|
|
||||||
let rank: String
|
|
||||||
let rating: String
|
|
||||||
let chapters: String
|
|
||||||
let url: String
|
|
||||||
let author: String
|
|
||||||
let status: String
|
|
||||||
let genres: [String]
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Ranking
|
// MARK: - Ranking
|
||||||
|
|
||||||
struct RankingItem: Codable, Identifiable {
|
struct RankingItem: Codable, Identifiable {
|
||||||
@@ -163,12 +217,6 @@ struct RankingItem: Codable, Identifiable {
|
|||||||
|
|
||||||
// MARK: - Home
|
// MARK: - Home
|
||||||
|
|
||||||
struct HomeData {
|
|
||||||
let continueReading: [ContinueReadingItem]
|
|
||||||
let recentlyUpdated: [Book]
|
|
||||||
let stats: HomeStats
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ContinueReadingItem: Identifiable {
|
struct ContinueReadingItem: Identifiable {
|
||||||
var id: String { book.id }
|
var id: String { book.id }
|
||||||
let book: Book
|
let book: Book
|
||||||
@@ -201,17 +249,6 @@ struct UserSession: Codable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Book Detail
|
|
||||||
|
|
||||||
struct BookDetailData {
|
|
||||||
let book: Book
|
|
||||||
let chapters: [ChapterIndex]
|
|
||||||
let previewChapters: [PreviewChapter]?
|
|
||||||
let inLib: Bool
|
|
||||||
let saved: Bool
|
|
||||||
let lastChapter: Int?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PreviewChapter: Codable, Identifiable {
|
struct PreviewChapter: Codable, Identifiable {
|
||||||
var id: Int { number }
|
var id: Int { number }
|
||||||
let number: Int
|
let number: Int
|
||||||
@@ -219,34 +256,54 @@ 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
|
||||||
let cover: String
|
let cover: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Comments
|
||||||
|
|
||||||
|
struct BookComment: Identifiable, Codable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let slug: String
|
||||||
|
let userId: String
|
||||||
|
let username: String
|
||||||
|
let body: String
|
||||||
|
var upvotes: Int
|
||||||
|
var downvotes: Int
|
||||||
|
let created: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, slug, username, body, upvotes, downvotes, created
|
||||||
|
case userId = "user_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(String.self, forKey: .id)
|
||||||
|
slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? ""
|
||||||
|
userId = try c.decodeIfPresent(String.self, forKey: .userId) ?? ""
|
||||||
|
username = try c.decodeIfPresent(String.self, forKey: .username) ?? ""
|
||||||
|
body = try c.decodeIfPresent(String.self, forKey: .body) ?? ""
|
||||||
|
upvotes = try c.decodeIfPresent(Int.self, forKey: .upvotes) ?? 0
|
||||||
|
downvotes = try c.decodeIfPresent(Int.self, forKey: .downvotes) ?? 0
|
||||||
|
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CommentsResponse: Decodable {
|
||||||
|
let comments: [BookComment]
|
||||||
|
let myVotes: [String: String]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case comments
|
||||||
|
case myVotes = "myVotes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Audio
|
// MARK: - Audio
|
||||||
|
|
||||||
enum NextPrefetchStatus {
|
enum NextPrefetchStatus {
|
||||||
case none, prefetching, prefetched, failed
|
case none, prefetching, prefetched, failed
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - PocketBase list response
|
|
||||||
|
|
||||||
struct PBList<T: Codable>: Codable {
|
|
||||||
let items: [T]
|
|
||||||
let totalItems: Int
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ actor APIClient {
|
|||||||
|
|
||||||
var baseURL: URL
|
var baseURL: URL
|
||||||
private var authCookie: String? // raw "libnovel_auth=<token>" header value
|
private var authCookie: String? // raw "libnovel_auth=<token>" header value
|
||||||
private var sessionId: String? // anon session id (UUID)
|
|
||||||
|
|
||||||
// URLSession with persistent cookie storage
|
// URLSession with persistent cookie storage
|
||||||
private let session: URLSession = {
|
private let session: URLSession = {
|
||||||
@@ -51,10 +50,6 @@ actor APIClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setSessionId(_ id: String) {
|
|
||||||
sessionId = id
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Low-level request builder
|
// MARK: - Low-level request builder
|
||||||
|
|
||||||
private func makeRequest(_ path: String, method: String = "GET", body: Encodable? = nil) throws -> URLRequest {
|
private func makeRequest(_ path: String, method: String = "GET", body: Encodable? = nil) throws -> URLRequest {
|
||||||
@@ -63,9 +58,7 @@ actor APIClient {
|
|||||||
// paths like /api/chapter/slug/1. URL(string:) preserves slashes correctly.
|
// 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 +68,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,51 +77,19 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchRaw(_ path: String, method: String = "GET", body: Encodable? = nil) async throws -> (Data, HTTPURLResponse) {
|
|
||||||
let req = try makeRequest(path, method: method, body: body)
|
|
||||||
let (data, response) = try await session.data(for: req)
|
|
||||||
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
|
|
||||||
return (data, http)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Auth
|
// MARK: - Auth
|
||||||
|
|
||||||
struct LoginRequest: Encodable {
|
struct LoginRequest: Encodable {
|
||||||
@@ -158,7 +113,7 @@ actor APIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func logout() async throws {
|
func logout() async throws {
|
||||||
let (_, _) = try await fetchRaw("/api/auth/logout", method: "POST")
|
let _: EmptyResponse = try await fetch("/api/auth/logout", method: "POST")
|
||||||
await setAuthCookie(nil)
|
await setAuthCookie(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,13 +151,6 @@ actor APIClient {
|
|||||||
|
|
||||||
// MARK: - Browse
|
// MARK: - Browse
|
||||||
|
|
||||||
struct BrowseParams: Encodable {
|
|
||||||
let page: Int
|
|
||||||
let genre: String
|
|
||||||
let sort: String
|
|
||||||
let status: String
|
|
||||||
}
|
|
||||||
|
|
||||||
func browse(page: Int, genre: String = "all", sort: String = "popular", status: String = "all") async throws -> BrowseResponse {
|
func browse(page: Int, genre: String = "all", sort: String = "popular", status: String = "all") async throws -> BrowseResponse {
|
||||||
let query = "?page=\(page)&genre=\(genre)&sort=\(sort)&status=\(status)"
|
let query = "?page=\(page)&genre=\(genre)&sort=\(sort)&status=\(status)"
|
||||||
return try await fetch("/api/browse-page\(query)")
|
return try await fetch("/api/browse-page\(query)")
|
||||||
@@ -316,6 +264,83 @@ actor APIClient {
|
|||||||
func revokeSession(id: String) async throws {
|
func revokeSession(id: String) async throws {
|
||||||
let _: EmptyResponse = try await fetch("/api/sessions/\(id)", method: "DELETE")
|
let _: EmptyResponse = try await fetch("/api/sessions/\(id)", method: "DELETE")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Avatar
|
||||||
|
|
||||||
|
struct AvatarPresignResponse: Decodable {
|
||||||
|
let uploadURL: String
|
||||||
|
let key: String
|
||||||
|
enum CodingKeys: String, CodingKey { case uploadURL = "upload_url"; case key }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AvatarResponse: Decodable {
|
||||||
|
let avatarURL: String?
|
||||||
|
enum CodingKeys: String, CodingKey { case avatarURL = "avatar_url" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload a profile avatar using a two-step presigned PUT flow:
|
||||||
|
/// 1. POST /api/profile/avatar → get a presigned PUT URL + object key
|
||||||
|
/// 2. PUT image bytes directly to MinIO via the presigned URL
|
||||||
|
/// 3. PATCH /api/profile/avatar with the key to record it in PocketBase
|
||||||
|
/// Returns the presigned GET URL for the uploaded avatar.
|
||||||
|
func uploadAvatar(_ imageData: Data, mimeType: String = "image/jpeg") async throws -> String? {
|
||||||
|
// Step 1: request a presigned PUT URL from the SvelteKit server
|
||||||
|
let presign: AvatarPresignResponse = try await fetch(
|
||||||
|
"/api/profile/avatar",
|
||||||
|
method: "POST",
|
||||||
|
body: ["mime_type": mimeType]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Step 2: PUT the image bytes directly to MinIO
|
||||||
|
guard let putURL = URL(string: presign.uploadURL) else { throw APIError.invalidResponse }
|
||||||
|
var putReq = URLRequest(url: putURL)
|
||||||
|
putReq.httpMethod = "PUT"
|
||||||
|
putReq.setValue(mimeType, forHTTPHeaderField: "Content-Type")
|
||||||
|
putReq.httpBody = imageData
|
||||||
|
|
||||||
|
let (_, putResp) = try await session.data(for: putReq)
|
||||||
|
guard let putHttp = putResp as? HTTPURLResponse,
|
||||||
|
(200..<300).contains(putHttp.statusCode) else {
|
||||||
|
let code = (putResp as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
throw APIError.httpError(code, "MinIO PUT failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: record the key in PocketBase and get back a presigned GET URL
|
||||||
|
let result: AvatarResponse = try await fetch(
|
||||||
|
"/api/profile/avatar",
|
||||||
|
method: "PATCH",
|
||||||
|
body: ["key": presign.key]
|
||||||
|
)
|
||||||
|
return result.avatarURL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches a fresh presigned GET URL for the current user's avatar.
|
||||||
|
/// Returns nil if the user has no avatar set.
|
||||||
|
/// Used on cold launch / session restore to convert the stored raw key into a viewable URL.
|
||||||
|
func fetchAvatarPresignedURL() async throws -> String? {
|
||||||
|
let result: AvatarResponse = try await fetch("/api/profile/avatar")
|
||||||
|
return result.avatarURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Comments
|
||||||
|
|
||||||
|
func fetchComments(slug: String) async throws -> CommentsResponse {
|
||||||
|
try await fetch("/api/comments/\(slug)")
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PostCommentBody: Encodable { let body: String }
|
||||||
|
|
||||||
|
func postComment(slug: String, body: String) async throws -> BookComment {
|
||||||
|
try await fetch("/api/comments/\(slug)", method: "POST", body: PostCommentBody(body: body))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VoteBody: Encodable { let vote: String }
|
||||||
|
|
||||||
|
/// Cast, change, or toggle-off a vote on a comment.
|
||||||
|
/// Returns the updated BookComment (with refreshed upvotes/downvotes counts).
|
||||||
|
func voteComment(commentId: String, vote: String) async throws -> BookComment {
|
||||||
|
try await fetch("/api/comments/\(commentId)/vote", method: "POST", body: VoteBody(vote: vote))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Response types
|
// MARK: - Response types
|
||||||
@@ -386,11 +411,6 @@ struct BrowseResponse: Decodable {
|
|||||||
let novels: [BrowseNovel]
|
let novels: [BrowseNovel]
|
||||||
let page: Int
|
let page: Int
|
||||||
let hasNext: Bool
|
let hasNext: Bool
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case novels, page
|
|
||||||
case hasNext = "hasNext"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BrowseNovel: Decodable, Identifiable, Hashable {
|
struct BrowseNovel: Decodable, Identifiable, Hashable {
|
||||||
|
|||||||
@@ -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 |
@@ -2,22 +2,30 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleExecutable</key>
|
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
|
||||||
<key>CFBundleName</key>
|
|
||||||
<string>LibNovel</string>
|
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>LibNovel</string>
|
<string>LibNovel</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>cc.kalekber.libnovel</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleName</key>
|
||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>LibNovel</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
|
||||||
<string>$(MARKETING_VERSION)</string>
|
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1000</string>
|
||||||
|
<key>LIBNOVEL_BASE_URL</key>
|
||||||
|
<string>$(LIBNOVEL_BASE_URL)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict/>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
@@ -31,13 +39,5 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UILaunchScreen</key>
|
|
||||||
<dict/>
|
|
||||||
<key>UIBackgroundModes</key>
|
|
||||||
<array>
|
|
||||||
<string>audio</string>
|
|
||||||
</array>
|
|
||||||
<key>LIBNOVEL_BASE_URL</key>
|
|
||||||
<string>$(LIBNOVEL_BASE_URL)</string>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -2,6 +2,20 @@ import Foundation
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import MediaPlayer
|
import MediaPlayer
|
||||||
import Combine
|
import Combine
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
|
// MARK: - PlaybackProgress
|
||||||
|
// Isolated ObservableObject for high-frequency playback state (currentTime,
|
||||||
|
// duration, isPlaying). Keeping these separate from AudioPlayerService means
|
||||||
|
// the 0.5-second time-observer ticks only invalidate views that explicitly
|
||||||
|
// observe PlaybackProgress — menus and other stable UI are unaffected.
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PlaybackProgress: ObservableObject {
|
||||||
|
@Published var currentTime: Double = 0
|
||||||
|
@Published var duration: Double = 0
|
||||||
|
@Published var isPlaying: Bool = false
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - AudioPlayerService
|
// MARK: - AudioPlayerService
|
||||||
// Central singleton that owns AVPlayer, drives audio state, handles lock-screen
|
// Central singleton that owns AVPlayer, drives audio state, handles lock-screen
|
||||||
@@ -27,14 +41,33 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
@Published var errorMessage: String = ""
|
@Published var 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 sleepTimerOption: SleepTimerOption = .off
|
|
||||||
|
@Published var sleepTimer: SleepTimerOption? = nil
|
||||||
|
/// Human-readable countdown string shown in the full player near the moon button.
|
||||||
|
/// e.g. "38:12" for minute-based, "2 ch left" for chapter-based, "" when off.
|
||||||
|
@Published var sleepTimerRemainingText: String = ""
|
||||||
|
|
||||||
@Published var nextPrefetchStatus: NextPrefetchStatus = .none
|
@Published var nextPrefetchStatus: NextPrefetchStatus = .none
|
||||||
@Published var nextAudioURL: String = ""
|
@Published var nextAudioURL: String = ""
|
||||||
@@ -58,13 +91,18 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
private var generationTask: Task<Void, Never>?
|
private var generationTask: Task<Void, Never>?
|
||||||
private var prefetchTask: Task<Void, Never>?
|
private var prefetchTask: Task<Void, Never>?
|
||||||
|
|
||||||
private var sleepTimer: Timer?
|
|
||||||
private var sleepRemainingChapters: Int?
|
|
||||||
|
|
||||||
// Cached cover image — downloaded once per chapter load, reused on every
|
// Cached cover image — downloaded once per chapter load, reused on every
|
||||||
// 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
|
||||||
|
/// Absolute deadline for minute-based timers (nil when not active or chapter-based).
|
||||||
|
private var sleepTimerDeadline: Date? = nil
|
||||||
|
/// 1-second tick task that keeps sleepTimerRemainingText up-to-date.
|
||||||
|
private var sleepTimerCountdownTask: Task<Void, Never>? = nil
|
||||||
|
|
||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
|
|
||||||
@@ -78,7 +116,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()
|
||||||
@@ -92,9 +130,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
|
||||||
@@ -144,6 +188,69 @@ 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 + countdown
|
||||||
|
sleepTimerTask?.cancel()
|
||||||
|
sleepTimerTask = nil
|
||||||
|
sleepTimerCountdownTask?.cancel()
|
||||||
|
sleepTimerCountdownTask = nil
|
||||||
|
sleepTimerDeadline = nil
|
||||||
|
|
||||||
|
sleepTimer = option
|
||||||
|
|
||||||
|
guard let option else {
|
||||||
|
sleepTimerRemainingText = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start timer based on option
|
||||||
|
switch option {
|
||||||
|
case .chapters(let count):
|
||||||
|
sleepTimerStartChapter = chapter
|
||||||
|
// Update display immediately; chapter changes are tracked in handlePlaybackFinished.
|
||||||
|
updateChapterTimerLabel(chaptersRemaining: count)
|
||||||
|
|
||||||
|
case .minutes(let minutes):
|
||||||
|
let deadline = Date().addingTimeInterval(Double(minutes) * 60)
|
||||||
|
sleepTimerDeadline = deadline
|
||||||
|
// Stop playback when the deadline is reached.
|
||||||
|
sleepTimerTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(minutes) * 60 * 1_000_000_000)
|
||||||
|
guard let self, !Task.isCancelled else { return }
|
||||||
|
await MainActor.run {
|
||||||
|
self.stop()
|
||||||
|
self.sleepTimer = nil
|
||||||
|
self.sleepTimerRemainingText = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 1-second tick to keep the countdown label fresh.
|
||||||
|
sleepTimerCountdownTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
guard let self, !Task.isCancelled else { return }
|
||||||
|
await MainActor.run {
|
||||||
|
guard let deadline = self.sleepTimerDeadline else { return }
|
||||||
|
let remaining = max(0, deadline.timeIntervalSinceNow)
|
||||||
|
self.sleepTimerRemainingText = Self.formatCountdown(remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Set initial label without waiting for the first tick.
|
||||||
|
sleepTimerRemainingText = Self.formatCountdown(Double(minutes) * 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateChapterTimerLabel(chaptersRemaining: Int) {
|
||||||
|
sleepTimerRemainingText = chaptersRemaining == 1 ? "1 ch left" : "\(chaptersRemaining) ch left"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func formatCountdown(_ seconds: Double) -> String {
|
||||||
|
let s = Int(max(0, seconds))
|
||||||
|
let m = s / 60
|
||||||
|
let sec = s % 60
|
||||||
|
return "\(m):\(String(format: "%02d", sec))"
|
||||||
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
player?.pause()
|
player?.pause()
|
||||||
@@ -153,49 +260,15 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
duration = 0
|
duration = 0
|
||||||
audioURL = ""
|
audioURL = ""
|
||||||
status = .idle
|
status = .idle
|
||||||
clearSleepTimerIfNeeded()
|
|
||||||
}
|
// Cancel sleep timer + countdown
|
||||||
|
sleepTimerTask?.cancel()
|
||||||
func playChapter(number: Int) {
|
sleepTimerTask = nil
|
||||||
guard !slug.isEmpty else { return }
|
sleepTimerCountdownTask?.cancel()
|
||||||
let title = chapters.first(where: { $0.number == number })?.title ?? ""
|
sleepTimerCountdownTask = nil
|
||||||
let next = chapters.first(where: { $0.number > number })?.number
|
sleepTimerDeadline = nil
|
||||||
load(
|
|
||||||
slug: slug,
|
|
||||||
chapter: number,
|
|
||||||
chapterTitle: title,
|
|
||||||
bookTitle: bookTitle,
|
|
||||||
coverURL: coverURL,
|
|
||||||
voice: voice,
|
|
||||||
speed: speed,
|
|
||||||
chapters: chapters,
|
|
||||||
nextChapter: next
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setSleepTimer(_ option: SleepTimerOption) {
|
|
||||||
sleepTimer?.invalidate()
|
|
||||||
sleepTimer = nil
|
sleepTimer = nil
|
||||||
sleepRemainingChapters = nil
|
sleepTimerRemainingText = ""
|
||||||
sleepTimerOption = option
|
|
||||||
|
|
||||||
switch option {
|
|
||||||
case .off:
|
|
||||||
break
|
|
||||||
case .endOfChapter:
|
|
||||||
sleepRemainingChapters = 1
|
|
||||||
case .chapters(let count):
|
|
||||||
sleepRemainingChapters = max(1, count)
|
|
||||||
case .minutes(let minutes):
|
|
||||||
let seconds = max(60, minutes * 60)
|
|
||||||
sleepTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(seconds), repeats: false) { [weak self] _ in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.stop()
|
|
||||||
self?.sleepTimerOption = .off
|
|
||||||
self?.sleepRemainingChapters = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Audio generation
|
// MARK: - Audio generation
|
||||||
@@ -357,51 +430,38 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
playerItem = nil
|
playerItem = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func clearSleepTimerIfNeeded() {
|
|
||||||
sleepTimer?.invalidate()
|
|
||||||
sleepTimer = nil
|
|
||||||
sleepRemainingChapters = nil
|
|
||||||
sleepTimerOption = .off
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handlePlaybackFinished() {
|
private func handlePlaybackFinished() {
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
|
|
||||||
var shouldAutoNext = autoNext
|
|
||||||
var didTriggerSleepStop = false
|
|
||||||
if let remaining = sleepRemainingChapters {
|
|
||||||
let updated = remaining - 1
|
|
||||||
if updated <= 0 {
|
|
||||||
sleepRemainingChapters = nil
|
|
||||||
sleepTimerOption = .off
|
|
||||||
didTriggerSleepStop = true
|
|
||||||
shouldAutoNext = false
|
|
||||||
} else {
|
|
||||||
sleepRemainingChapters = updated
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if didTriggerSleepStop {
|
|
||||||
stop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let next = nextChapter else { return }
|
guard let next = nextChapter else { return }
|
||||||
|
|
||||||
|
// Check chapter-based sleep timer
|
||||||
|
if case .chapters(let count) = sleepTimer {
|
||||||
|
let chaptersPlayed = chapter - sleepTimerStartChapter + 1
|
||||||
|
if chaptersPlayed >= count {
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Update the remaining chapters label.
|
||||||
|
let remaining = count - chaptersPlayed
|
||||||
|
updateChapterTimerLabel(chaptersRemaining: remaining)
|
||||||
|
}
|
||||||
|
|
||||||
// Always notify the view that the chapter finished (it may update UI).
|
// Always notify the view that the chapter finished (it may update UI).
|
||||||
NotificationCenter.default.post(
|
NotificationCenter.default.post(
|
||||||
name: .audioDidFinishChapter,
|
name: .audioDidFinishChapter,
|
||||||
object: nil,
|
object: nil,
|
||||||
userInfo: ["next": next, "autoNext": shouldAutoNext]
|
userInfo: ["next": next, "autoNext": autoNext]
|
||||||
)
|
)
|
||||||
|
|
||||||
// If autoNext is on, load the next chapter internally right away.
|
// If autoNext is on, load the next chapter internally right away.
|
||||||
// We already have the metadata in `chapters`, so we can reconstruct
|
// We already have the metadata in `chapters`, so we can reconstruct
|
||||||
// everything without waiting for the view to navigate.
|
// everything without waiting for the view to navigate.
|
||||||
guard shouldAutoNext else { return }
|
guard autoNext else { return }
|
||||||
|
|
||||||
let nextTitle = chapters.first(where: { $0.number == next })?.title ?? ""
|
let 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.
|
||||||
@@ -412,12 +472,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)
|
||||||
@@ -434,7 +500,8 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
voice: voice,
|
voice: voice,
|
||||||
speed: speed,
|
speed: speed,
|
||||||
chapters: chapters,
|
chapters: chapters,
|
||||||
nextChapter: nextNextChapter
|
nextChapter: nextNextChapter,
|
||||||
|
prevChapter: nextPrevChapter
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -443,14 +510,17 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
|
|
||||||
private func prefetchCoverArtwork(from urlString: String) {
|
private func prefetchCoverArtwork(from urlString: String) {
|
||||||
guard !urlString.isEmpty, let url = URL(string: urlString) else { return }
|
guard !urlString.isEmpty, let url = URL(string: urlString) else { return }
|
||||||
URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
|
KingfisherManager.shared.retrieveImage(with: url) { [weak self] result in
|
||||||
guard let self, let data, let image = UIImage(data: data) else { return }
|
guard let self else { return }
|
||||||
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
if case .success(let value) = result {
|
||||||
Task { @MainActor in
|
let image = value.image
|
||||||
self.cachedCoverArtwork = artwork
|
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
||||||
self.updateNowPlaying()
|
Task { @MainActor in
|
||||||
|
self.cachedCoverArtwork = artwork
|
||||||
|
self.updateNowPlaying()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}.resume()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Audio Session
|
// MARK: - Audio Session
|
||||||
@@ -480,9 +550,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]
|
||||||
@@ -535,12 +605,12 @@ enum AudioPlayerStatus: Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum SleepTimerOption: Equatable {
|
enum SleepTimerOption: Equatable {
|
||||||
case off
|
case chapters(Int) // Stop after N chapters
|
||||||
case endOfChapter
|
case minutes(Int) // Stop after N minutes
|
||||||
case chapters(Int)
|
|
||||||
case minutes(Int)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,14 @@ final class AuthStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Token validation (on cold launch)
|
// MARK: - Token validation
|
||||||
|
|
||||||
|
/// Re-validates the current session and refreshes `user` + `settings`.
|
||||||
|
/// Call this after any operation that may change the user record (e.g. avatar upload).
|
||||||
|
func validateToken() async {
|
||||||
|
guard let token = loadToken() else { return }
|
||||||
|
await validateToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
private func validateToken(_ token: String) async {
|
private func validateToken(_ token: String) async {
|
||||||
await APIClient.shared.setAuthCookie(token)
|
await APIClient.shared.setAuthCookie(token)
|
||||||
@@ -93,7 +100,20 @@ final class AuthStore: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
async let me: AppUser = APIClient.shared.fetch("/api/auth/me")
|
async let me: AppUser = APIClient.shared.fetch("/api/auth/me")
|
||||||
async let s: UserSettings = APIClient.shared.settings()
|
async let s: UserSettings = APIClient.shared.settings()
|
||||||
let (restoredUser, restoredSettings) = try await (me, s)
|
var (restoredUser, restoredSettings) = try await (me, s)
|
||||||
|
// /api/auth/me returns the raw MinIO object key for avatar_url, not a presigned URL.
|
||||||
|
// Exchange the key for a fresh presigned GET URL so KFImage can display it.
|
||||||
|
if let key = restoredUser.avatarURL, !key.hasPrefix("http") {
|
||||||
|
if let presignedURL = try? await APIClient.shared.fetchAvatarPresignedURL() {
|
||||||
|
restoredUser = AppUser(
|
||||||
|
id: restoredUser.id,
|
||||||
|
username: restoredUser.username,
|
||||||
|
role: restoredUser.role,
|
||||||
|
created: restoredUser.created,
|
||||||
|
avatarURL: presignedURL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
user = restoredUser
|
user = restoredUser
|
||||||
settings = restoredSettings
|
settings = restoredSettings
|
||||||
} catch let e as APIError {
|
} catch let e as APIError {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ final class BookDetailViewModel: ObservableObject {
|
|||||||
@Published var saved: Bool = false
|
@Published var saved: Bool = false
|
||||||
@Published var lastChapter: Int?
|
@Published var lastChapter: Int?
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var chaptersLoading = false
|
|
||||||
@Published var error: String?
|
@Published var error: String?
|
||||||
|
|
||||||
init(slug: String) {
|
init(slug: String) {
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ struct AuthView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.navigationBarHidden(true)
|
.toolbar(.hidden, for: .navigationBar)
|
||||||
}
|
}
|
||||||
.onChange(of: mode) { _, _ in
|
.onChange(of: mode) { _, _ in
|
||||||
authStore.error = nil
|
authStore.error = nil
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
struct BookDetailView: View {
|
struct BookDetailView: View {
|
||||||
let slug: String
|
let slug: String
|
||||||
@@ -15,24 +16,28 @@ struct BookDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ZStack(alignment: .top) {
|
||||||
if vm.isLoading {
|
// Scroll content
|
||||||
ProgressView().frame(maxWidth: .infinity).padding(.top, 80)
|
ScrollView {
|
||||||
} else if let book = vm.book {
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
heroSection(book: book)
|
if vm.isLoading {
|
||||||
Divider().padding(.vertical, 8)
|
ProgressView().frame(maxWidth: .infinity).padding(.top, 120)
|
||||||
chapterSection(book: book)
|
} else if let book = vm.book {
|
||||||
|
heroSection(book: book)
|
||||||
|
metaSection(book: book)
|
||||||
|
Divider().padding(.horizontal)
|
||||||
|
chapterSection(book: book)
|
||||||
|
Divider().padding(.horizontal)
|
||||||
|
CommentsView(slug: slug)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.ignoresSafeArea(edges: .top)
|
||||||
}
|
}
|
||||||
.navigationTitle("")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.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
|
||||||
@@ -40,85 +45,148 @@ struct BookDetailView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func heroSection(book: Book) -> some View {
|
private func heroSection(book: Book) -> some View {
|
||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .bottom) {
|
||||||
// Blurred cover background — use plain colour placeholder to avoid
|
// Full-bleed blurred background
|
||||||
// the rounded-rect loading indicator showing through the blur.
|
KFImage(URL(string: book.cover))
|
||||||
AsyncCoverImage(url: book.cover, isBackground: true)
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: 260)
|
.frame(height: 320)
|
||||||
.blur(radius: 20)
|
.blur(radius: 24)
|
||||||
.clipped()
|
.clipped()
|
||||||
.overlay(Color.black.opacity(0.45))
|
.overlay(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.black.opacity(0.15), .black.opacity(0.68)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
HStack(alignment: .bottom, spacing: 14) {
|
// Cover + info column centered
|
||||||
AsyncCoverImage(url: book.cover)
|
VStack(spacing: 16) {
|
||||||
.frame(width: 110, height: 160)
|
// Isolated cover with 3D-style shadow
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
KFImage(URL(string: book.cover))
|
||||||
.shadow(radius: 8)
|
.resizable()
|
||||||
|
.placeholder {
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color(.systemGray5))
|
||||||
|
}
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: 130, height: 188)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.shadow(color: .black.opacity(0.55), radius: 18, x: 0, y: 10)
|
||||||
|
.shadow(color: .black.opacity(0.3), radius: 6, x: 0, y: 3)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
// Title + author
|
||||||
|
VStack(spacing: 6) {
|
||||||
Text(book.title)
|
Text(book.title)
|
||||||
.font(.headline)
|
.font(.title3.bold())
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
.lineLimit(3)
|
.lineLimit(3)
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
|
||||||
Text(book.author)
|
Text(book.author)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.white.opacity(0.8))
|
.foregroundStyle(.white.opacity(0.75))
|
||||||
HStack {
|
}
|
||||||
TagChip(label: book.status).colorScheme(.dark)
|
|
||||||
ForEach(book.genres.prefix(2), id: \.self) {
|
// Genre tags
|
||||||
TagChip(label: $0).colorScheme(.dark)
|
if !book.genres.isEmpty {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(book.genres.prefix(3), id: \.self) { genre in
|
||||||
|
TagChip(label: genre).colorScheme(.dark)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
|
||||||
|
// Status badge
|
||||||
|
if !book.status.isEmpty {
|
||||||
|
StatusBadge(status: book.status)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.bottom, 16)
|
.padding(.bottom, 28)
|
||||||
}
|
}
|
||||||
|
.frame(minHeight: 320)
|
||||||
|
}
|
||||||
|
|
||||||
// Summary
|
// MARK: - Meta section (summary + CTAs)
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text(book.summary)
|
@ViewBuilder
|
||||||
.font(.subheadline)
|
private func metaSection(book: Book) -> some View {
|
||||||
.foregroundStyle(.secondary)
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
.lineLimit(summaryExpanded ? nil : 4)
|
// Quick stats row
|
||||||
if book.summary.count > 200 {
|
HStack(spacing: 0) {
|
||||||
Button(summaryExpanded ? "Less" : "More") {
|
MetaStat(value: "\(book.totalChapters)", label: "Chapters",
|
||||||
withAnimation { summaryExpanded.toggle() }
|
icon: "doc.text")
|
||||||
|
Divider().frame(height: 36)
|
||||||
|
MetaStat(value: book.status.capitalized.isEmpty ? "—" : book.status.capitalized,
|
||||||
|
label: "Status", icon: "flag")
|
||||||
|
if book.ranking > 0 {
|
||||||
|
Divider().frame(height: 36)
|
||||||
|
MetaStat(value: "#\(book.ranking)", label: "Rank",
|
||||||
|
icon: "chart.bar.fill")
|
||||||
}
|
}
|
||||||
.font(.caption.bold())
|
|
||||||
.foregroundStyle(.amber)
|
|
||||||
}
|
}
|
||||||
}
|
.padding(.vertical, 16)
|
||||||
.padding()
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
// CTA buttons
|
Divider().padding(.horizontal)
|
||||||
HStack(spacing: 10) {
|
|
||||||
if let last = vm.lastChapter, last > 0 {
|
|
||||||
NavigationLink(value: NavDestination.chapter(slug, last)) {
|
|
||||||
Label("Continue Ch.\(last)", systemImage: "play.fill")
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.tint(.amber)
|
|
||||||
|
|
||||||
NavigationLink(value: NavDestination.chapter(slug, 1)) {
|
// Summary
|
||||||
Label("From Ch.1", systemImage: "arrow.counterclockwise")
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
.frame(maxWidth: .infinity)
|
Text("About")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text(book.summary)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(summaryExpanded ? nil : 4)
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: summaryExpanded)
|
||||||
|
|
||||||
|
if book.summary.count > 200 {
|
||||||
|
Button(summaryExpanded ? "Less" : "More") {
|
||||||
|
withAnimation { summaryExpanded.toggle() }
|
||||||
|
}
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.amber)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.tint(.secondary)
|
|
||||||
} else {
|
|
||||||
NavigationLink(value: NavDestination.chapter(slug, 1)) {
|
|
||||||
Label("Start Reading", systemImage: "book.fill")
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.tint(.amber)
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
|
||||||
|
Divider().padding(.horizontal)
|
||||||
|
|
||||||
|
// CTA buttons
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
if let last = vm.lastChapter, last > 0 {
|
||||||
|
NavigationLink(value: NavDestination.chapter(slug, last)) {
|
||||||
|
Label("Continue Ch.\(last)", systemImage: "play.fill")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.amber)
|
||||||
|
|
||||||
|
NavigationLink(value: NavDestination.chapter(slug, 1)) {
|
||||||
|
Label("Ch.1", systemImage: "arrow.counterclockwise")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.secondary)
|
||||||
|
} else {
|
||||||
|
NavigationLink(value: NavDestination.chapter(slug, 1)) {
|
||||||
|
Label("Start Reading", systemImage: "book.fill")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.amber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 16)
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.bottom, 8)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Chapter list
|
// MARK: - Chapter list
|
||||||
@@ -132,9 +200,10 @@ struct BookDetailView: View {
|
|||||||
let pageChapters = Array(chapters[start..<end])
|
let pageChapters = Array(chapters[start..<end])
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
// Section header
|
||||||
HStack {
|
HStack {
|
||||||
Text("Chapters")
|
Text("Chapters")
|
||||||
.font(.title3.bold())
|
.font(.headline)
|
||||||
Spacer()
|
Spacer()
|
||||||
if total > 0 {
|
if total > 0 {
|
||||||
Text("\(start + 1)–\(end) of \(total)")
|
Text("\(start + 1)–\(end) of \(total)")
|
||||||
@@ -143,36 +212,58 @@ struct BookDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 14)
|
||||||
|
|
||||||
if vm.chaptersLoading {
|
if vm.isLoading {
|
||||||
ProgressView().frame(maxWidth: .infinity).padding()
|
ProgressView().frame(maxWidth: .infinity).padding()
|
||||||
} else {
|
} else {
|
||||||
ForEach(pageChapters) { ch in
|
ForEach(pageChapters) { ch in
|
||||||
NavigationLink(value: NavDestination.chapter(slug, ch.number)) {
|
NavigationLink(value: NavDestination.chapter(slug, ch.number)) {
|
||||||
ChapterRow(chapter: ch, isCurrent: ch.number == vm.lastChapter)
|
ChapterRow(chapter: ch, isCurrent: ch.number == vm.lastChapter,
|
||||||
|
totalChapters: total)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
Divider().padding(.leading)
|
Divider().padding(.leading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pagination
|
// Pagination bar
|
||||||
if total > pageSize {
|
if total > pageSize {
|
||||||
HStack {
|
HStack {
|
||||||
Button("Previous") { chapterPage -= 1 }
|
Button {
|
||||||
.disabled(chapterPage == 0)
|
withAnimation { chapterPage -= 1 }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
Text("Previous")
|
||||||
|
}
|
||||||
|
.disabled(chapterPage == 0)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Next") { chapterPage += 1 }
|
|
||||||
.disabled(end >= total)
|
Text("Page \(chapterPage + 1) of \((total + pageSize - 1) / pageSize)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
withAnimation { chapterPage += 1 }
|
||||||
|
} label: {
|
||||||
|
Text("Next")
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
}
|
||||||
|
.disabled(end >= total)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.amber)
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Color.clear.frame(height: 32)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Toolbar bookmark
|
// MARK: - Bookmark toolbar
|
||||||
|
|
||||||
@ToolbarContentBuilder
|
@ToolbarContentBuilder
|
||||||
private var bookmarkButton: some ToolbarContent {
|
private var bookmarkButton: some ToolbarContent {
|
||||||
@@ -187,38 +278,113 @@ struct BookDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Chapter row
|
||||||
|
|
||||||
private struct ChapterRow: View {
|
private struct ChapterRow: View {
|
||||||
let chapter: ChapterIndex
|
let chapter: ChapterIndex
|
||||||
let isCurrent: Bool
|
let isCurrent: Bool
|
||||||
|
let totalChapters: Int
|
||||||
|
|
||||||
|
private var progressFraction: Double {
|
||||||
|
guard totalChapters > 1 else { return 0 }
|
||||||
|
return Double(chapter.number) / Double(totalChapters)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 10) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
// Number badge
|
||||||
Text("Chapter \(chapter.number)")
|
ZStack {
|
||||||
.font(.subheadline)
|
Circle()
|
||||||
.fontWeight(isCurrent ? .bold : .regular)
|
.fill(isCurrent ? Color.amber : Color(.systemGray6))
|
||||||
.foregroundStyle(isCurrent ? .amber : .primary)
|
Text("\(chapter.number)")
|
||||||
if !chapter.title.isEmpty && chapter.title != "Chapter \(chapter.number)" {
|
.font(.caption2.bold().monospacedDigit())
|
||||||
Text(chapter.title)
|
.foregroundStyle(isCurrent ? .black : .secondary)
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Spacer(minLength: 12)
|
.frame(width: 32, height: 32)
|
||||||
HStack(spacing: 6) {
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
let displayTitle: String = {
|
||||||
|
let stripped = chapter.title.strippingTrailingDate()
|
||||||
|
if stripped.isEmpty || stripped == "Chapter \(chapter.number)" {
|
||||||
|
return "Chapter \(chapter.number)"
|
||||||
|
}
|
||||||
|
return stripped
|
||||||
|
}()
|
||||||
|
|
||||||
|
Text(displayTitle)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(isCurrent ? .semibold : .regular)
|
||||||
|
.foregroundStyle(isCurrent ? .amber : .primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
if !chapter.dateLabel.isEmpty {
|
if !chapter.dateLabel.isEmpty {
|
||||||
Text(chapter.dateLabel)
|
Text(chapter.dateLabel)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
.fixedSize()
|
|
||||||
}
|
}
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting components
|
||||||
|
|
||||||
|
private struct MetaStat: View {
|
||||||
|
let value: String
|
||||||
|
let label: String
|
||||||
|
let icon: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.amber)
|
||||||
|
Text(value)
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.7)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct StatusBadge: View {
|
||||||
|
let status: String
|
||||||
|
|
||||||
|
private var color: Color {
|
||||||
|
switch status.lowercased() {
|
||||||
|
case "ongoing", "active": return .green
|
||||||
|
case "completed": return .blue
|
||||||
|
case "hiatus": return .orange
|
||||||
|
default: return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
Text(status.capitalized)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(color.opacity(0.12), in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
320
ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift
Normal file
320
ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - ViewModel
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class CommentsViewModel: ObservableObject {
|
||||||
|
let slug: String
|
||||||
|
|
||||||
|
@Published var comments: [BookComment] = []
|
||||||
|
@Published var myVotes: [String: String] = [:] // commentId → "up" | "down"
|
||||||
|
@Published var isLoading = true
|
||||||
|
@Published var error: String?
|
||||||
|
|
||||||
|
@Published var newBody = ""
|
||||||
|
@Published var isPosting = false
|
||||||
|
@Published var postError: String?
|
||||||
|
|
||||||
|
private var votingIds: Set<String> = []
|
||||||
|
|
||||||
|
init(slug: String) {
|
||||||
|
self.slug = slug
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
do {
|
||||||
|
let response = try await APIClient.shared.fetchComments(slug: slug)
|
||||||
|
comments = response.comments
|
||||||
|
myVotes = response.myVotes
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func postComment() async {
|
||||||
|
let text = newBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty, !isPosting else { return }
|
||||||
|
if text.count > 2000 {
|
||||||
|
postError = "Comment too long (max 2000 characters)."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isPosting = true
|
||||||
|
postError = nil
|
||||||
|
do {
|
||||||
|
let created = try await APIClient.shared.postComment(slug: slug, body: text)
|
||||||
|
comments.insert(created, at: 0)
|
||||||
|
newBody = ""
|
||||||
|
} catch let apiError as APIError {
|
||||||
|
switch apiError {
|
||||||
|
case .httpError(401, _): postError = "You must be logged in to comment."
|
||||||
|
default: postError = apiError.localizedDescription
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
postError = error.localizedDescription
|
||||||
|
}
|
||||||
|
isPosting = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func vote(commentId: String, vote: String) async {
|
||||||
|
guard !votingIds.contains(commentId) else { return }
|
||||||
|
votingIds.insert(commentId)
|
||||||
|
defer { votingIds.remove(commentId) }
|
||||||
|
do {
|
||||||
|
let updated = try await APIClient.shared.voteComment(commentId: commentId, vote: vote)
|
||||||
|
// Update the comment in the list
|
||||||
|
if let idx = comments.firstIndex(where: { $0.id == commentId }) {
|
||||||
|
comments[idx] = updated
|
||||||
|
}
|
||||||
|
// Toggle myVotes
|
||||||
|
let prev = myVotes[commentId]
|
||||||
|
if prev == vote {
|
||||||
|
myVotes.removeValue(forKey: commentId)
|
||||||
|
} else {
|
||||||
|
myVotes[commentId] = vote
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently ignore vote errors — don't disrupt the UI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVoting(_ commentId: String) -> Bool {
|
||||||
|
votingIds.contains(commentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CommentsView
|
||||||
|
|
||||||
|
struct CommentsView: View {
|
||||||
|
@StateObject private var vm: CommentsViewModel
|
||||||
|
@EnvironmentObject private var authStore: AuthStore
|
||||||
|
|
||||||
|
init(slug: String) {
|
||||||
|
_vm = StateObject(wrappedValue: CommentsViewModel(slug: slug))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
// Section header
|
||||||
|
HStack {
|
||||||
|
Text("Comments")
|
||||||
|
.font(.headline)
|
||||||
|
if !vm.isLoading && !vm.comments.isEmpty {
|
||||||
|
Text("(\(vm.comments.count))")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
|
||||||
|
Divider().padding(.horizontal)
|
||||||
|
|
||||||
|
// Post form
|
||||||
|
postForm
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
|
||||||
|
Divider().padding(.horizontal)
|
||||||
|
|
||||||
|
// Comment list
|
||||||
|
if vm.isLoading {
|
||||||
|
loadingPlaceholder
|
||||||
|
} else if let err = vm.error {
|
||||||
|
Text(err)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.padding()
|
||||||
|
} else if vm.comments.isEmpty {
|
||||||
|
Text("No comments yet. Be the first!")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding()
|
||||||
|
} else {
|
||||||
|
ForEach(vm.comments) { comment in
|
||||||
|
CommentRow(
|
||||||
|
comment: comment,
|
||||||
|
myVote: vm.myVotes[comment.id],
|
||||||
|
isVoting: vm.isVoting(comment.id)
|
||||||
|
) { vote in
|
||||||
|
Task { await vm.vote(commentId: comment.id, vote: vote) }
|
||||||
|
}
|
||||||
|
Divider().padding(.leading, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color.clear.frame(height: 16)
|
||||||
|
}
|
||||||
|
.task { await vm.load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Post form
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var postForm: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
if vm.newBody.isEmpty {
|
||||||
|
Text("Write a comment…")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.leading, 4)
|
||||||
|
}
|
||||||
|
TextEditor(text: $vm.newBody)
|
||||||
|
.font(.subheadline)
|
||||||
|
.frame(minHeight: 72, maxHeight: 160)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
let count = vm.newBody.count
|
||||||
|
Text("\(count)/2000")
|
||||||
|
.font(.caption2)
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(count > 2000 ? .red : .tertiary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let err = vm.postError {
|
||||||
|
Text(err)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await vm.postComment() }
|
||||||
|
} label: {
|
||||||
|
if vm.isPosting {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Text("Post")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.amber)
|
||||||
|
.controlSize(.small)
|
||||||
|
.disabled(vm.isPosting || vm.newBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.newBody.count > 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Loading skeleton
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var loadingPlaceholder: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ForEach(0..<3, id: \.self) { _ in
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(Color(.systemGray5))
|
||||||
|
.frame(width: 100, height: 12)
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(Color(.systemGray6))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 12)
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(Color(.systemGray6))
|
||||||
|
.frame(width: 200, height: 12)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.redacted(reason: .placeholder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CommentRow
|
||||||
|
|
||||||
|
private struct CommentRow: View {
|
||||||
|
let comment: BookComment
|
||||||
|
let myVote: String?
|
||||||
|
let isVoting: Bool
|
||||||
|
let onVote: (String) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
// Username + date
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(comment.username.isEmpty ? "Anonymous" : comment.username)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
Text("·")
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
Text(formattedDate(comment.created))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body
|
||||||
|
Text(comment.body)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
// Vote row
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
// Upvote
|
||||||
|
Button {
|
||||||
|
onVote("up")
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: myVote == "up" ? "hand.thumbsup.fill" : "hand.thumbsup")
|
||||||
|
.font(.caption)
|
||||||
|
Text("\(comment.upvotes)")
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
}
|
||||||
|
.foregroundStyle(myVote == "up" ? Color.amber : .secondary)
|
||||||
|
}
|
||||||
|
.disabled(isVoting)
|
||||||
|
|
||||||
|
// Downvote
|
||||||
|
Button {
|
||||||
|
onVote("down")
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: myVote == "down" ? "hand.thumbsdown.fill" : "hand.thumbsdown")
|
||||||
|
.font(.caption)
|
||||||
|
Text("\(comment.downvotes)")
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
}
|
||||||
|
.foregroundStyle(myVote == "down" ? .red : .secondary)
|
||||||
|
}
|
||||||
|
.disabled(isVoting)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.opacity(isVoting ? 0.6 : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formattedDate(_ iso: String) -> String {
|
||||||
|
// PocketBase returns "2006-01-02 15:04:05.999Z" format
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
if let date = formatter.date(from: iso) {
|
||||||
|
let rel = RelativeDateTimeFormatter()
|
||||||
|
rel.unitsStyle = .abbreviated
|
||||||
|
return rel.localizedString(for: date, relativeTo: Date())
|
||||||
|
}
|
||||||
|
// Fallback: try space-separated format
|
||||||
|
let df = DateFormatter()
|
||||||
|
df.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ"
|
||||||
|
if let date = df.date(from: iso) {
|
||||||
|
let rel = RelativeDateTimeFormatter()
|
||||||
|
rel.unitsStyle = .abbreviated
|
||||||
|
return rel.localizedString(for: date, relativeTo: Date())
|
||||||
|
}
|
||||||
|
return String(iso.prefix(10))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,13 +29,13 @@ struct BrowseView: View {
|
|||||||
// Filter chips row
|
// Filter chips row
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
FilterChip(label: "Sort: \(vm.sort.capitalized)", isActive: vm.sort != "popular") {
|
ChipButton(label: "Sort: \(vm.sort.capitalized)", isSelected: vm.sort != "popular", style: .outlined) {
|
||||||
showFilters = true
|
showFilters = true
|
||||||
}
|
}
|
||||||
FilterChip(label: "Genre: \(vm.genre == "all" ? "All" : vm.genre.capitalized)", isActive: vm.genre != "all") {
|
ChipButton(label: "Genre: \(vm.genre == "all" ? "All" : vm.genre.capitalized)", isSelected: vm.genre != "all", style: .outlined) {
|
||||||
showFilters = true
|
showFilters = true
|
||||||
}
|
}
|
||||||
FilterChip(label: "Status: \(vm.status == "all" ? "All" : vm.status.capitalized)", isActive: vm.status != "all") {
|
ChipButton(label: "Status: \(vm.status == "all" ? "All" : vm.status.capitalized)", isSelected: vm.status != "all", style: .outlined) {
|
||||||
showFilters = true
|
showFilters = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,6 +72,7 @@ struct BrowseView: View {
|
|||||||
ForEach(vm.novels) { novel in
|
ForEach(vm.novels) { novel in
|
||||||
NavigationLink(value: NavDestination.book(novel.slug)) {
|
NavigationLink(value: NavDestination.book(novel.slug)) {
|
||||||
BrowseCard(novel: novel)
|
BrowseCard(novel: novel)
|
||||||
|
.bookCoverZoomSource(slug: novel.slug)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@@ -90,12 +91,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)
|
||||||
}
|
}
|
||||||
@@ -107,25 +103,6 @@ struct BrowseView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Filter chip
|
|
||||||
|
|
||||||
private struct FilterChip: View {
|
|
||||||
let label: String
|
|
||||||
let isActive: Bool
|
|
||||||
let action: () -> Void
|
|
||||||
var body: some View {
|
|
||||||
Button(action: action) {
|
|
||||||
Text(label)
|
|
||||||
.font(.caption.bold())
|
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(isActive ? Color.amber.opacity(0.15) : Color(.systemGray6), in: Capsule())
|
|
||||||
.foregroundStyle(isActive ? .amber : .primary)
|
|
||||||
.overlay(Capsule().strokeBorder(isActive ? Color.amber : .clear, lineWidth: 1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Browse card
|
// MARK: - Browse card
|
||||||
|
|
||||||
private struct BrowseCard: View {
|
private struct BrowseCard: View {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -80,3 +80,64 @@ struct TagChip: View {
|
|||||||
.background(Color(.systemGray5), in: Capsule())
|
.background(Color(.systemGray5), in: Capsule())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Unified chip button (filter/sort chips across all screens)
|
||||||
|
//
|
||||||
|
// .filled → amber background when selected (genre filter chips in Library)
|
||||||
|
// .outlined → amber border + tint when selected, grey background (sort chips, browse filter chips)
|
||||||
|
|
||||||
|
enum ChipButtonStyle { case filled, outlined }
|
||||||
|
|
||||||
|
struct ChipButton: View {
|
||||||
|
let label: String
|
||||||
|
let isSelected: Bool
|
||||||
|
var style: ChipButtonStyle = .filled
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(label)
|
||||||
|
.font(chipFont)
|
||||||
|
.padding(.horizontal, chipHPad)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(background)
|
||||||
|
.foregroundStyle(foregroundColor)
|
||||||
|
.overlay(border)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var chipFont: Font {
|
||||||
|
switch style {
|
||||||
|
case .filled: return .caption.weight(isSelected ? .semibold : .regular)
|
||||||
|
case .outlined: return .subheadline.weight(isSelected ? .semibold : .regular)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var chipHPad: CGFloat { style == .outlined ? 14 : 12 }
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var background: some View {
|
||||||
|
switch style {
|
||||||
|
case .filled:
|
||||||
|
Capsule().fill(isSelected ? Color.amber : Color(.systemGray5))
|
||||||
|
case .outlined:
|
||||||
|
Capsule()
|
||||||
|
.fill(isSelected ? Color.amber.opacity(0.15) : Color(.systemGray6))
|
||||||
|
.overlay(Capsule().stroke(isSelected ? Color.amber : .clear, lineWidth: 1.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var foregroundColor: Color {
|
||||||
|
switch style {
|
||||||
|
case .filled: return isSelected ? .white : .primary
|
||||||
|
case .outlined: return isSelected ? .amber : .primary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var border: some View {
|
||||||
|
// outlined style already has its border baked into `background`
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Kingfisher
|
|
||||||
|
|
||||||
struct HomeView: View {
|
struct HomeView: View {
|
||||||
@StateObject private var vm = HomeViewModel()
|
@StateObject private var vm = HomeViewModel()
|
||||||
@@ -8,51 +7,58 @@ struct HomeView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 28) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
|
||||||
// Stats bar
|
// Large hero continue card (most recent in-progress book)
|
||||||
|
if let hero = vm.continueReading.first {
|
||||||
|
HeroContinueCard(item: hero)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 28)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue reading shelf (remaining items after the hero)
|
||||||
|
let shelf = vm.continueReading.dropFirst()
|
||||||
|
if !shelf.isEmpty {
|
||||||
|
ShelfHeader(title: "Continue Reading")
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(alignment: .top, spacing: 14) {
|
||||||
|
ForEach(Array(shelf)) { item in
|
||||||
|
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||||
|
ContinueReadingCard(item: item)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 28)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats strip
|
||||||
if let stats = vm.stats {
|
if let stats = vm.stats {
|
||||||
HStack(spacing: 0) {
|
StatsStrip(stats: stats)
|
||||||
StatCell(value: "\(stats.totalBooks)", label: "Books")
|
.padding(.horizontal)
|
||||||
Divider().frame(height: 32)
|
.padding(.bottom, 28)
|
||||||
StatCell(value: "\(stats.totalChapters)", label: "Chapters")
|
|
||||||
Divider().frame(height: 32)
|
|
||||||
StatCell(value: "\(stats.booksInProgress)", label: "In Progress")
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 16)
|
|
||||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14))
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Continue reading
|
// Recently updated shelf
|
||||||
if !vm.continueReading.isEmpty {
|
|
||||||
SectionHeader(title: "Continue Reading")
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
HStack(alignment: .top, spacing: 12) {
|
|
||||||
ForEach(vm.continueReading) { item in
|
|
||||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
|
||||||
ContinueReadingCard(item: item)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recently updated
|
|
||||||
if !vm.recentlyUpdated.isEmpty {
|
if !vm.recentlyUpdated.isEmpty {
|
||||||
SectionHeader(title: "Recently Updated")
|
ShelfHeader(title: "Recently Updated")
|
||||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 16) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
ForEach(vm.recentlyUpdated) { book in
|
HStack(alignment: .top, spacing: 14) {
|
||||||
NavigationLink(value: NavDestination.book(book.slug)) {
|
ForEach(vm.recentlyUpdated) { book in
|
||||||
BookCard(book: book)
|
NavigationLink(value: NavDestination.book(book.slug)) {
|
||||||
|
ShelfBookCard(book: book)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 4)
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.bottom, 28)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty state
|
// Empty state
|
||||||
@@ -71,78 +77,227 @@ struct HomeView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.top, 60)
|
.padding(.top, 60)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.padding(.vertical)
|
Color.clear.frame(height: 20)
|
||||||
}
|
|
||||||
.navigationTitle("Home")
|
|
||||||
.navigationDestination(for: NavDestination.self) { dest in
|
|
||||||
switch dest {
|
|
||||||
case .book(let slug): BookDetailView(slug: slug)
|
|
||||||
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.navigationTitle("Reading Now")
|
||||||
|
.appNavigationDestination()
|
||||||
.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 ?? "")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Supporting components
|
// MARK: - Hero card (full-width, Apple Books "Now Playing" style)
|
||||||
|
|
||||||
|
private struct HeroContinueCard: View {
|
||||||
|
let item: ContinueReadingItem
|
||||||
|
|
||||||
private struct StatCell: View {
|
|
||||||
let value: String
|
|
||||||
let label: String
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 2) {
|
NavigationLink(value: NavDestination.chapter(item.book.slug, item.chapter)) {
|
||||||
Text(value).font(.title2.bold()).foregroundStyle(.primary)
|
ZStack(alignment: .bottomLeading) {
|
||||||
Text(label).font(.caption).foregroundStyle(.secondary)
|
// Blurred background
|
||||||
|
AsyncCoverImage(url: item.book.cover, isBackground: true)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 220)
|
||||||
|
.blur(radius: 22)
|
||||||
|
.clipped()
|
||||||
|
// Depth gradient: subtle amber tint at top, deep shadow at bottom
|
||||||
|
.overlay(
|
||||||
|
LinearGradient(
|
||||||
|
stops: [
|
||||||
|
.init(color: Color(red: 0.18, green: 0.12, blue: 0.02).opacity(0.55), location: 0),
|
||||||
|
.init(color: .black.opacity(0.15), location: 0.35),
|
||||||
|
.init(color: .black.opacity(0.78), location: 1)
|
||||||
|
],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Content: cover on left, info stacked on right
|
||||||
|
HStack(alignment: .bottom, spacing: 14) {
|
||||||
|
AsyncCoverImage(url: item.book.cover)
|
||||||
|
.frame(width: 96, height: 138)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.shadow(color: .black.opacity(0.55), radius: 12, y: 6)
|
||||||
|
.bookCoverZoomSource(slug: item.book.slug)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
// Progress indicator
|
||||||
|
if item.book.totalChapters > 0 {
|
||||||
|
let pct = min(1.0, Double(item.chapter) / Double(item.book.totalChapters))
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Capsule().fill(Color.white.opacity(0.2))
|
||||||
|
Capsule().fill(Color.amber.opacity(0.85))
|
||||||
|
.frame(width: geo.size.width * pct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 3)
|
||||||
|
.frame(maxWidth: 140)
|
||||||
|
|
||||||
|
Text("\(Int(pct * 100))% complete")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.white.opacity(0.55))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(item.book.title)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
Text(item.book.author)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.white.opacity(0.65))
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "play.fill")
|
||||||
|
.font(.caption.bold())
|
||||||
|
Text("Continue Ch.\(item.chapter)")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(.black.opacity(0.85))
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
.background(Capsule().fill(Color.amber))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.shadow(color: .black.opacity(0.25), radius: 14, y: 5)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SectionHeader: View {
|
// MARK: - Shelf header
|
||||||
|
|
||||||
|
private struct ShelfHeader: View {
|
||||||
let title: String
|
let title: String
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.title3.bold())
|
.font(.title3.bold())
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Horizontal shelf: continue reading card
|
||||||
|
|
||||||
private struct ContinueReadingCard: View {
|
private struct ContinueReadingCard: View {
|
||||||
let item: ContinueReadingItem
|
let item: ContinueReadingItem
|
||||||
|
|
||||||
|
private var progressFraction: Double {
|
||||||
|
guard item.book.totalChapters > 0 else { return 0 }
|
||||||
|
return min(1.0, Double(item.chapter) / Double(item.book.totalChapters))
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
KFImage(URL(string: item.book.cover))
|
ZStack(alignment: .bottomTrailing) {
|
||||||
.resizable()
|
AsyncCoverImage(url: item.book.cover)
|
||||||
.placeholder { coverPlaceholder }
|
.frame(width: 110, height: 158)
|
||||||
.scaledToFill()
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
.frame(width: 120, height: 170)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
// Progress arc ring + chapter badge
|
||||||
.overlay(alignment: .bottomTrailing) {
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.white.opacity(0.18), lineWidth: 2.5)
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: progressFraction)
|
||||||
|
.stroke(Color.amber, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
Text("Ch.\(item.chapter)")
|
Text("Ch.\(item.chapter)")
|
||||||
.font(.caption2.bold())
|
.font(.system(size: 8, weight: .bold))
|
||||||
.padding(.horizontal, 6)
|
.foregroundStyle(.white)
|
||||||
.padding(.vertical, 3)
|
.minimumScaleFactor(0.6)
|
||||||
.background(.ultraThinMaterial, in: Capsule())
|
|
||||||
.padding(6)
|
|
||||||
}
|
}
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.background(.ultraThinMaterial, in: Circle())
|
||||||
|
.padding(5)
|
||||||
|
}
|
||||||
Text(item.book.title)
|
Text(item.book.title)
|
||||||
.font(.caption.bold())
|
.font(.caption.bold())
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.frame(width: 120, alignment: .leading)
|
.frame(width: 110, alignment: .leading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private var coverPlaceholder: some View {
|
}
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.fill(Color(.systemGray5))
|
// MARK: - Horizontal shelf: recently updated book card
|
||||||
.frame(width: 120, height: 170)
|
|
||||||
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
|
private struct ShelfBookCard: View {
|
||||||
|
let book: Book
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
AsyncCoverImage(url: book.cover)
|
||||||
|
.frame(width: 110, height: 158)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
|
||||||
|
.bookCoverZoomSource(slug: book.slug)
|
||||||
|
|
||||||
|
Text(book.title)
|
||||||
|
.font(.caption.bold())
|
||||||
|
.lineLimit(2)
|
||||||
|
.frame(width: 110, alignment: .leading)
|
||||||
|
|
||||||
|
Text(book.author)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(width: 110, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stats strip (compact inline)
|
||||||
|
|
||||||
|
private struct StatsStrip: View {
|
||||||
|
let stats: HomeStats
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
StatPill(icon: "books.vertical.fill", value: "\(stats.totalBooks)", label: "Books")
|
||||||
|
Divider().frame(height: 28)
|
||||||
|
StatPill(icon: "text.alignleft", value: "\(stats.totalChapters)", label: "Chapters")
|
||||||
|
Divider().frame(height: 28)
|
||||||
|
StatPill(icon: "bookmark.fill", value: "\(stats.booksInProgress)", label: "In Progress")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct StatPill: View {
|
||||||
|
let icon: String
|
||||||
|
let value: String
|
||||||
|
let label: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(Color.amber.opacity(0.8))
|
||||||
|
Text(value)
|
||||||
|
.font(.subheadline.bold().monospacedDigit())
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,83 @@ import Kingfisher
|
|||||||
|
|
||||||
struct LibraryView: View {
|
struct LibraryView: View {
|
||||||
@StateObject private var vm = LibraryViewModel()
|
@StateObject private var vm = LibraryViewModel()
|
||||||
|
@State private var sortOrder: SortOrder = .recentlyRead
|
||||||
|
@State private var readingFilter: ReadingFilter = .all
|
||||||
|
@State private var selectedGenre: String = "all"
|
||||||
|
@State private var searchText = ""
|
||||||
|
|
||||||
|
enum SortOrder: String, CaseIterable {
|
||||||
|
case recentlyRead = "Recent"
|
||||||
|
case title = "Title"
|
||||||
|
case author = "Author"
|
||||||
|
case progress = "Progress"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReadingFilter: String, CaseIterable {
|
||||||
|
case all = "All"
|
||||||
|
case inProgress = "In Progress"
|
||||||
|
case completed = "Completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
// All distinct genres across the library, sorted alphabetically.
|
||||||
|
private var availableGenres: [String] {
|
||||||
|
let all = vm.items.flatMap { $0.book.genres }
|
||||||
|
let unique = Array(Set(all)).sorted()
|
||||||
|
return unique
|
||||||
|
}
|
||||||
|
|
||||||
|
private var filtered: [LibraryItem] {
|
||||||
|
var result = vm.items
|
||||||
|
|
||||||
|
// 1. Reading filter
|
||||||
|
switch readingFilter {
|
||||||
|
case .all:
|
||||||
|
break
|
||||||
|
case .inProgress:
|
||||||
|
result = result.filter { !isCompleted($0) }
|
||||||
|
case .completed:
|
||||||
|
result = result.filter { isCompleted($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Genre filter
|
||||||
|
if selectedGenre != "all" {
|
||||||
|
result = result.filter { $0.book.genres.contains(selectedGenre) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Sort
|
||||||
|
switch sortOrder {
|
||||||
|
case .recentlyRead:
|
||||||
|
break // server returns by recency
|
||||||
|
case .title:
|
||||||
|
result = result.sorted { $0.book.title < $1.book.title }
|
||||||
|
case .author:
|
||||||
|
result = result.sorted { $0.book.author < $1.book.author }
|
||||||
|
case .progress:
|
||||||
|
result = result.sorted { ($0.lastChapter ?? 0) > ($1.lastChapter ?? 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Search
|
||||||
|
if !searchText.isEmpty {
|
||||||
|
result = result.filter {
|
||||||
|
$0.book.title.localizedCaseInsensitiveContains(searchText) ||
|
||||||
|
$0.book.author.localizedCaseInsensitiveContains(searchText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isCompleted(_ item: LibraryItem) -> Bool {
|
||||||
|
// Treat as completed if book status is "completed" OR
|
||||||
|
// the user has read up to (or past) the total chapter count.
|
||||||
|
if item.book.status.lowercased() == "completed",
|
||||||
|
let ch = item.lastChapter,
|
||||||
|
item.book.totalChapters > 0,
|
||||||
|
ch >= item.book.totalChapters {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return item.book.status.lowercased() == "completed" && (item.lastChapter ?? 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -18,69 +95,226 @@ struct LibraryView: View {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVGrid(
|
VStack(spacing: 0) {
|
||||||
columns: [GridItem(.adaptive(minimum: 150), spacing: 12)],
|
// Search bar
|
||||||
spacing: 16
|
HStack(spacing: 8) {
|
||||||
) {
|
Image(systemName: "magnifyingglass")
|
||||||
ForEach(vm.items) { item in
|
.foregroundStyle(.secondary)
|
||||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
TextField("Search library", text: $searchText)
|
||||||
LibraryCard(item: item)
|
.font(.subheadline)
|
||||||
|
if !searchText.isEmpty {
|
||||||
|
Button { searchText = "" } label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
// Reading filter (All / In Progress / Completed)
|
||||||
|
Picker("", selection: $readingFilter) {
|
||||||
|
ForEach(ReadingFilter.allCases, id: \.self) { f in
|
||||||
|
Text(f.rawValue).tag(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
// Genre filter chips (only shown when genres are available)
|
||||||
|
if !availableGenres.isEmpty {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
// "All" chip
|
||||||
|
ChipButton(
|
||||||
|
label: "All",
|
||||||
|
isSelected: selectedGenre == "all",
|
||||||
|
style: .filled
|
||||||
|
) {
|
||||||
|
withAnimation { selectedGenre = "all" }
|
||||||
|
}
|
||||||
|
ForEach(availableGenres, id: \.self) { genre in
|
||||||
|
ChipButton(
|
||||||
|
label: genre.capitalized,
|
||||||
|
isSelected: selectedGenre == genre,
|
||||||
|
style: .filled
|
||||||
|
) {
|
||||||
|
withAnimation {
|
||||||
|
selectedGenre = selectedGenre == genre ? "all" : genre
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
.padding(.top, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort chips
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(SortOrder.allCases, id: \.self) { order in
|
||||||
|
ChipButton(
|
||||||
|
label: order.rawValue,
|
||||||
|
isSelected: sortOrder == order,
|
||||||
|
style: .outlined
|
||||||
|
) {
|
||||||
|
withAnimation { sortOrder = order }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
|
||||||
|
// Book count
|
||||||
|
Text("\(filtered.count) book\(filtered.count == 1 ? "" : "s")")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
if filtered.isEmpty {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: readingFilter == .completed ? "checkmark.circle" : "book")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(emptyMessage)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 60)
|
||||||
|
} else {
|
||||||
|
// 3-column grid
|
||||||
|
LazyVGrid(
|
||||||
|
columns: [
|
||||||
|
GridItem(.flexible(), spacing: 12),
|
||||||
|
GridItem(.flexible(), spacing: 12),
|
||||||
|
GridItem(.flexible(), spacing: 12)
|
||||||
|
],
|
||||||
|
spacing: 20
|
||||||
|
) {
|
||||||
|
ForEach(filtered) { item in
|
||||||
|
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||||
|
LibraryBookCard(item: item)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 24)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Library")
|
.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 ?? "") }
|
}
|
||||||
|
|
||||||
|
private var emptyMessage: String {
|
||||||
|
switch readingFilter {
|
||||||
|
case .all:
|
||||||
|
return selectedGenre == "all" ? "No books match your search." : "No \(selectedGenre.capitalized) books in your library."
|
||||||
|
case .inProgress:
|
||||||
|
return "No books in progress."
|
||||||
|
case .completed:
|
||||||
|
return "No completed books yet."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct LibraryCard: View {
|
// MARK: - Library book card (3-column)
|
||||||
|
|
||||||
|
private struct LibraryBookCard: View {
|
||||||
let item: LibraryItem
|
let item: LibraryItem
|
||||||
|
|
||||||
|
private var progressFraction: Double {
|
||||||
|
guard let ch = item.lastChapter, item.book.totalChapters > 0 else { return 0 }
|
||||||
|
return Double(ch) / Double(item.book.totalChapters)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isCompleted: Bool {
|
||||||
|
progressFraction >= 1.0
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
ZStack(alignment: .topTrailing) {
|
||||||
|
// Cover image
|
||||||
KFImage(URL(string: item.book.cover))
|
KFImage(URL(string: item.book.cover))
|
||||||
.resizable()
|
.resizable()
|
||||||
.placeholder {
|
.placeholder {
|
||||||
RoundedRectangle(cornerRadius: 10)
|
RoundedRectangle(cornerRadius: 8)
|
||||||
.fill(Color(.systemGray5))
|
.fill(Color(.systemGray5))
|
||||||
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
|
.overlay(
|
||||||
|
Image(systemName: "book.closed")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.scaledToFill()
|
.scaledToFill()
|
||||||
.frame(height: 200)
|
.frame(maxWidth: .infinity)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.aspectRatio(2/3, contentMode: .fit)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.shadow(color: .black.opacity(0.14), radius: 4, y: 2)
|
||||||
|
.bookCoverZoomSource(slug: item.book.slug)
|
||||||
|
|
||||||
if let ch = item.lastChapter {
|
// Progress arc or completed checkmark in top-right corner
|
||||||
Text("Ch.\(ch)")
|
if isCompleted {
|
||||||
.font(.caption2.bold())
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.padding(.horizontal, 6)
|
.font(.system(size: 18, weight: .semibold))
|
||||||
.padding(.vertical, 3)
|
.foregroundStyle(.white)
|
||||||
.background(.ultraThinMaterial, in: Capsule())
|
.background(Circle().fill(Color.amber).padding(1))
|
||||||
.padding(6)
|
.padding(5)
|
||||||
|
} else if progressFraction > 0 {
|
||||||
|
ProgressArc(fraction: progressFraction)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.padding(4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Title
|
||||||
Text(item.book.title)
|
Text(item.book.title)
|
||||||
.font(.caption.bold())
|
.font(.caption.bold())
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
Text(item.book.author)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
// Chapter badge if present
|
||||||
.lineLimit(1)
|
if let ch = item.lastChapter {
|
||||||
|
Text(isCompleted ? "Finished" : "Ch.\(ch)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(isCompleted ? Color.amber : .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Circular progress arc overlay
|
||||||
|
|
||||||
|
private struct ProgressArc: View {
|
||||||
|
let fraction: Double // 0...1
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: fraction)
|
||||||
|
.stroke(Color.amber, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.animation(.easeInOut(duration: 0.5), value: fraction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
161
ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift
Normal file
161
ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - AvatarCropView
|
||||||
|
// A sheet that lets the user pan and pinch a photo to fill a 1:1 square crop region.
|
||||||
|
// Call: .sheet(item: $cropImage) { AvatarCropView(image: $0.image, onConfirm: { croppedData in … }) }
|
||||||
|
|
||||||
|
struct AvatarCropView: View {
|
||||||
|
let image: UIImage
|
||||||
|
let onConfirm: (Data) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
// Crop square side length (points) — matched to the web 400 px target
|
||||||
|
private let cropSize: CGFloat = 280
|
||||||
|
|
||||||
|
// Pan/zoom state
|
||||||
|
@State private var scale: CGFloat = 1.0
|
||||||
|
@State private var lastScale: CGFloat = 1.0
|
||||||
|
@State private var offset: CGSize = .zero
|
||||||
|
@State private var lastOffset: CGSize = .zero
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack {
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
|
|
||||||
|
// Draggable / pinchable image
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: geo.size.width, height: geo.size.height)
|
||||||
|
.scaleEffect(scale)
|
||||||
|
.offset(offset)
|
||||||
|
.gesture(
|
||||||
|
SimultaneousGesture(
|
||||||
|
MagnificationGesture()
|
||||||
|
.onChanged { value in
|
||||||
|
scale = max(1.0, lastScale * value)
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
lastScale = scale
|
||||||
|
},
|
||||||
|
DragGesture()
|
||||||
|
.onChanged { value in
|
||||||
|
offset = CGSize(
|
||||||
|
width: lastOffset.width + value.translation.width,
|
||||||
|
height: lastOffset.height + value.translation.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
lastOffset = offset
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.clipped()
|
||||||
|
|
||||||
|
// Dim overlay with transparent crop square cut out
|
||||||
|
CropOverlay(cropSize: cropSize, containerSize: geo.size)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Crop Photo")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Cancel", action: onCancel)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Use Photo") {
|
||||||
|
confirmCrop()
|
||||||
|
}
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(.amber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||||
|
}
|
||||||
|
.onAppear { fitImageInitially() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Crop
|
||||||
|
|
||||||
|
private func fitImageInitially() {
|
||||||
|
// Scale image so its shorter dimension fills the crop square
|
||||||
|
let imgAspect = image.size.width / image.size.height
|
||||||
|
if imgAspect > 1 {
|
||||||
|
// wider than tall — fit height to cropSize
|
||||||
|
scale = cropSize / image.size.height * (image.size.height / image.size.width)
|
||||||
|
} else {
|
||||||
|
scale = 1.0
|
||||||
|
}
|
||||||
|
scale = max(1.0, scale)
|
||||||
|
lastScale = scale
|
||||||
|
}
|
||||||
|
|
||||||
|
private func confirmCrop() {
|
||||||
|
// Render image at current pan/zoom into a 400×400 bitmap
|
||||||
|
let outputSize = CGSize(width: 400, height: 400)
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: outputSize)
|
||||||
|
let cropped = renderer.image { ctx in
|
||||||
|
// We need to map from the SwiftUI transform back to image pixels.
|
||||||
|
// We render the raw UIImage into the output rect, applying the same
|
||||||
|
// scale / offset proportionally (normalised by crop square / container).
|
||||||
|
let screenCropSize: CGFloat = cropSize
|
||||||
|
// Scale factor: pixels per SwiftUI point in the output
|
||||||
|
let outputScale = outputSize.width / screenCropSize
|
||||||
|
|
||||||
|
ctx.cgContext.translateBy(x: outputSize.width / 2, y: outputSize.height / 2)
|
||||||
|
ctx.cgContext.scaleBy(x: scale * outputScale, y: scale * outputScale)
|
||||||
|
ctx.cgContext.translateBy(
|
||||||
|
x: -image.size.width / 2 + (offset.width * outputScale / scale),
|
||||||
|
y: -image.size.height / 2 + (offset.height * outputScale / scale)
|
||||||
|
)
|
||||||
|
image.draw(at: .zero)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let jpeg = cropped.jpegData(compressionQuality: 0.9) {
|
||||||
|
onConfirm(jpeg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Crop overlay
|
||||||
|
|
||||||
|
private struct CropOverlay: View {
|
||||||
|
let cropSize: CGFloat
|
||||||
|
let containerSize: CGSize
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Canvas { context, size in
|
||||||
|
// Fill entire canvas with semi-transparent black
|
||||||
|
context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(.black.opacity(0.55)))
|
||||||
|
// Cut out the crop square in the centre
|
||||||
|
let origin = CGPoint(
|
||||||
|
x: (size.width - cropSize) / 2,
|
||||||
|
y: (size.height - cropSize) / 2
|
||||||
|
)
|
||||||
|
let cropRect = CGRect(origin: origin, size: CGSize(width: cropSize, height: cropSize))
|
||||||
|
context.blendMode = .destinationOut
|
||||||
|
context.fill(Path(ellipseIn: cropRect), with: .color(.white))
|
||||||
|
}
|
||||||
|
.compositingGroup()
|
||||||
|
.overlay {
|
||||||
|
// Amber circle border around the crop region
|
||||||
|
let origin = CGPoint(
|
||||||
|
x: (containerSize.width - cropSize) / 2,
|
||||||
|
y: (containerSize.height - cropSize) / 2
|
||||||
|
)
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.amber.opacity(0.8), lineWidth: 2)
|
||||||
|
.frame(width: cropSize, height: cropSize)
|
||||||
|
.position(
|
||||||
|
x: origin.x + cropSize / 2,
|
||||||
|
y: origin.y + cropSize / 2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(width: containerSize.width, height: containerSize.height)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,100 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import PhotosUI
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
struct ProfileView: View {
|
struct ProfileView: View {
|
||||||
@EnvironmentObject var authStore: AuthStore
|
@EnvironmentObject var authStore: AuthStore
|
||||||
@StateObject private var vm = ProfileViewModel()
|
@StateObject private var vm = ProfileViewModel()
|
||||||
@State private var showChangePassword = false
|
@State private var showChangePassword = false
|
||||||
|
|
||||||
|
// Avatar upload state
|
||||||
|
@State private var photoPickerItem: PhotosPickerItem?
|
||||||
|
@State private var pendingCropImage: UIImage? // image waiting to be cropped
|
||||||
|
@State private var avatarURL: String? = nil
|
||||||
|
@State private var avatarUploading = false
|
||||||
|
@State private var avatarError: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
// User header
|
// ── User header ────────────────────────────────────────────
|
||||||
Section {
|
Section {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 16) {
|
||||||
Image(systemName: "person.circle.fill")
|
// Tappable avatar circle
|
||||||
.font(.system(size: 48))
|
PhotosPicker(selection: $photoPickerItem,
|
||||||
.foregroundStyle(.amber)
|
matching: .images,
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
photoLibrary: .shared()) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color(.systemGray5))
|
||||||
|
.frame(width: 72, height: 72)
|
||||||
|
|
||||||
|
if avatarUploading {
|
||||||
|
ProgressView()
|
||||||
|
.frame(width: 72, height: 72)
|
||||||
|
} else if let urlStr = avatarURL ?? authStore.user?.avatarURL,
|
||||||
|
let url = URL(string: urlStr) {
|
||||||
|
KFImage(url)
|
||||||
|
.placeholder {
|
||||||
|
Image(systemName: "person.circle.fill")
|
||||||
|
.font(.system(size: 52))
|
||||||
|
.foregroundStyle(.amber)
|
||||||
|
}
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: 72, height: 72)
|
||||||
|
.clipShape(Circle())
|
||||||
|
} else {
|
||||||
|
Image(systemName: "person.circle.fill")
|
||||||
|
.font(.system(size: 52))
|
||||||
|
.foregroundStyle(.amber)
|
||||||
|
.frame(width: 72, height: 72)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera overlay badge
|
||||||
|
if !avatarUploading {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.amber)
|
||||||
|
.frame(width: 22, height: 22)
|
||||||
|
Image(systemName: "camera.fill")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(.black)
|
||||||
|
}
|
||||||
|
.offset(x: 2, y: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 72, height: 72)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.onChange(of: photoPickerItem) { _, item in
|
||||||
|
guard let item else { return }
|
||||||
|
Task { await loadImageForCrop(item) }
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text(authStore.user?.username ?? "")
|
Text(authStore.user?.username ?? "")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Text(authStore.user?.role.capitalized ?? "")
|
Text(authStore.user?.role.capitalized ?? "")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
if let err = avatarError {
|
||||||
|
Text(err)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reading settings
|
// ── Reading settings ───────────────────────────────────────
|
||||||
Section("Reading Settings") {
|
Section("Reading Settings") {
|
||||||
voicePicker
|
voicePicker
|
||||||
speedSlider
|
speedSlider
|
||||||
@@ -42,7 +111,7 @@ struct ProfileView: View {
|
|||||||
.tint(.amber)
|
.tint(.amber)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sessions
|
// ── Sessions ───────────────────────────────────────────────
|
||||||
Section("Active Sessions") {
|
Section("Active Sessions") {
|
||||||
if vm.sessionsLoading {
|
if vm.sessionsLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -55,7 +124,7 @@ struct ProfileView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Account
|
// ── Account ────────────────────────────────────────────────
|
||||||
Section("Account") {
|
Section("Account") {
|
||||||
Button("Change Password") { showChangePassword = true }
|
Button("Change Password") { showChangePassword = true }
|
||||||
Button("Sign Out", role: .destructive) {
|
Button("Sign Out", role: .destructive) {
|
||||||
@@ -64,13 +133,51 @@ struct ProfileView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Profile")
|
.navigationTitle("Profile")
|
||||||
.task { await vm.loadSessions() }
|
.task {
|
||||||
|
await vm.loadSessions()
|
||||||
|
}
|
||||||
.sheet(isPresented: $showChangePassword) {
|
.sheet(isPresented: $showChangePassword) {
|
||||||
ChangePasswordView()
|
ChangePasswordView()
|
||||||
}
|
}
|
||||||
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
|
.sheet(item: Binding(
|
||||||
Button("OK") { vm.error = nil }
|
get: { pendingCropImage.map { CropImageItem(image: $0) } },
|
||||||
} message: { Text(vm.error ?? "") }
|
set: { if $0 == nil { pendingCropImage = nil } }
|
||||||
|
)) { item in
|
||||||
|
AvatarCropView(image: item.image) { croppedData in
|
||||||
|
pendingCropImage = nil
|
||||||
|
Task { await uploadCroppedData(croppedData) }
|
||||||
|
} onCancel: {
|
||||||
|
pendingCropImage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.errorAlert($vm.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Avatar upload
|
||||||
|
|
||||||
|
/// Step 1: Load the raw image from the picker and show the crop sheet.
|
||||||
|
private func loadImageForCrop(_ item: PhotosPickerItem) async {
|
||||||
|
guard let data = try? await item.loadTransferable(type: Data.self),
|
||||||
|
let image = UIImage(data: data) else {
|
||||||
|
avatarError = "Could not read image"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingCropImage = image
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step 2: Called by AvatarCropView once the user confirms. Upload the cropped JPEG.
|
||||||
|
private func uploadCroppedData(_ data: Data) async {
|
||||||
|
avatarUploading = true
|
||||||
|
avatarError = nil
|
||||||
|
defer { avatarUploading = false }
|
||||||
|
do {
|
||||||
|
let url = try await APIClient.shared.uploadAvatar(data, mimeType: "image/jpeg")
|
||||||
|
avatarURL = url
|
||||||
|
// Refresh user record so the new avatar persists across sessions
|
||||||
|
await authStore.validateToken()
|
||||||
|
} catch {
|
||||||
|
avatarError = "Upload failed: \(error.localizedDescription)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +228,7 @@ struct ProfileView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
in: 0.5...3.0, step: 0.25
|
in: 0.5...2.0, step: 0.25
|
||||||
)
|
)
|
||||||
.tint(.amber)
|
.tint(.amber)
|
||||||
}
|
}
|
||||||
@@ -208,7 +315,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
|
||||||
}
|
}
|
||||||
@@ -216,3 +324,10 @@ struct ChangePasswordView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Crop image item (Identifiable wrapper for .sheet(item:))
|
||||||
|
|
||||||
|
private struct CropImageItem: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let image: UIImage
|
||||||
|
}
|
||||||
|
|||||||
36
ios/LibNovel/fastlane/Fastfile
Normal file
36
ios/LibNovel/fastlane/Fastfile
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
default_platform(:ios)
|
||||||
|
|
||||||
|
platform :ios do
|
||||||
|
desc "Build and upload to TestFlight"
|
||||||
|
lane :beta do
|
||||||
|
# Generate Xcode project from project.yml (one level up from fastlane/)
|
||||||
|
sh("cd .. && xcodegen generate --spec project.yml --project .")
|
||||||
|
|
||||||
|
# Set build number from CI run number (passed as env var)
|
||||||
|
increment_build_number(
|
||||||
|
build_number: ENV["BUILD_NUMBER"] || "1",
|
||||||
|
xcodeproj: "LibNovel.xcodeproj"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build the app - signing settings are in project.yml Release config
|
||||||
|
build_app(
|
||||||
|
scheme: "LibNovel",
|
||||||
|
export_method: "app-store",
|
||||||
|
clean: true,
|
||||||
|
configuration: "Release",
|
||||||
|
export_options: {
|
||||||
|
method: "app-store",
|
||||||
|
teamID: "GHZXC6FVMU",
|
||||||
|
provisioningProfiles: {
|
||||||
|
"com.kalekber.LibNovel" => "LibNovel Distribution"
|
||||||
|
},
|
||||||
|
signingStyle: "manual"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upload to TestFlight
|
||||||
|
upload_to_testflight(
|
||||||
|
skip_waiting_for_build_processing: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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,19 @@ 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
|
||||||
|
CODE_SIGN_IDENTITY: "Apple Distribution"
|
||||||
|
PROVISIONING_PROFILE: "af592c3a-f60b-4ac1-a14f-30b8a206017f"
|
||||||
|
|
||||||
LibNovelTests:
|
LibNovelTests:
|
||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
@@ -65,7 +66,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:
|
||||||
|
|||||||
32
ios/LibNovel/test-build.sh
Executable file
32
ios/LibNovel/test-build.sh
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Test script for local iOS build iteration
|
||||||
|
# Run from ios/LibNovel directory
|
||||||
|
|
||||||
|
echo "=== Generating Xcode project ==="
|
||||||
|
xcodegen generate --spec project.yml --project .
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Listing available provisioning profiles ==="
|
||||||
|
ls -la ~/Library/MobileDevice/Provisioning\ Profiles/ || echo "No profiles found"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Listing available signing identities ==="
|
||||||
|
security find-identity -v -p codesigning
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Attempting archive build ==="
|
||||||
|
xcodebuild archive \
|
||||||
|
-project LibNovel.xcodeproj \
|
||||||
|
-scheme LibNovel \
|
||||||
|
-configuration Release \
|
||||||
|
-destination 'generic/platform=iOS' \
|
||||||
|
-archivePath ./build/LibNovel.xcarchive \
|
||||||
|
-allowProvisioningUpdates \
|
||||||
|
CODE_SIGN_STYLE=Manual \
|
||||||
|
CODE_SIGN_IDENTITY="Apple Distribution" \
|
||||||
|
DEVELOPMENT_TEAM="GHZXC6FVMU"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Build succeeded! ==="
|
||||||
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)
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ func run(log *slog.Logger) error {
|
|||||||
BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"),
|
BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"),
|
||||||
BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"),
|
BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"),
|
||||||
BucketBrowse: envOr("MINIO_BUCKET_BROWSE", "libnovel-browse"),
|
BucketBrowse: envOr("MINIO_BUCKET_BROWSE", "libnovel-browse"),
|
||||||
|
BucketAvatars: envOr("MINIO_BUCKET_AVATARS", "libnovel-avatars"),
|
||||||
}
|
}
|
||||||
pbCfg := storage.PocketBaseConfig{
|
pbCfg := storage.PocketBaseConfig{
|
||||||
BaseURL: envOr("POCKETBASE_URL", "http://localhost:8090"),
|
BaseURL: envOr("POCKETBASE_URL", "http://localhost:8090"),
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,13 @@ func (s *mockStore) PresignChapter(_ context.Context, _ string, _ int, _ time.Du
|
|||||||
func (s *mockStore) PresignAudio(_ context.Context, _ string, _ time.Duration) (string, error) {
|
func (s *mockStore) PresignAudio(_ context.Context, _ string, _ time.Duration) (string, error) {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
func (s *mockStore) PresignAvatarUpload(_ context.Context, _, _ string) (string, string, error) {
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
func (s *mockStore) PresignAvatarURL(_ context.Context, _ string) (string, bool, error) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
func (s *mockStore) DeleteAvatar(_ context.Context, _ string) error { return nil }
|
||||||
func (s *mockStore) SaveBrowsePage(_ context.Context, _, _ string) error { return nil }
|
func (s *mockStore) SaveBrowsePage(_ context.Context, _, _ string) error { return nil }
|
||||||
func (s *mockStore) GetBrowsePage(_ context.Context, _ string) (string, bool, error) {
|
func (s *mockStore) GetBrowsePage(_ context.Context, _ string) (string, bool, error) {
|
||||||
return "", false, nil
|
return "", false, nil
|
||||||
|
|||||||
@@ -649,3 +649,64 @@ func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]string{"url": url})
|
_ = json.NewEncoder(w).Encode(map[string]string{"url": url})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handlePresignAvatarUpload handles GET /api/presign/avatar-upload/{userId}.
|
||||||
|
// Returns a short-lived presigned PUT URL for uploading an avatar image directly
|
||||||
|
// to MinIO, along with the object key to record in PocketBase after the upload.
|
||||||
|
// Query param: ext — image extension (jpg, png, webp). Defaults to "jpg".
|
||||||
|
func (s *Server) handlePresignAvatarUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := r.PathValue("userId")
|
||||||
|
if userID == "" {
|
||||||
|
http.Error(w, `{"error":"missing userId"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := r.URL.Query().Get("ext")
|
||||||
|
switch ext {
|
||||||
|
case "jpg", "jpeg":
|
||||||
|
ext = "jpg"
|
||||||
|
case "png":
|
||||||
|
ext = "png"
|
||||||
|
case "webp":
|
||||||
|
ext = "webp"
|
||||||
|
default:
|
||||||
|
ext = "jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadURL, key, err := s.store.PresignAvatarUpload(r.Context(), userID, ext)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("presign avatar upload failed", "userId", userID, "err", err)
|
||||||
|
http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"upload_url": uploadURL,
|
||||||
|
"key": key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePresignAvatar handles GET /api/presign/avatar/{userId}.
|
||||||
|
// Returns a presigned GET URL for a user's existing avatar, or 404 if none.
|
||||||
|
func (s *Server) handlePresignAvatar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := r.PathValue("userId")
|
||||||
|
if userID == "" {
|
||||||
|
http.Error(w, `{"error":"missing userId"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
url, found, err := s.store.PresignAvatarURL(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("presign avatar failed", "userId", userID, "err", err)
|
||||||
|
http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"url": url})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,8 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
|||||||
mux.HandleFunc("GET /api/presign/chapter/{slug}/{n}", s.handlePresignChapter)
|
mux.HandleFunc("GET /api/presign/chapter/{slug}/{n}", s.handlePresignChapter)
|
||||||
mux.HandleFunc("GET /api/presign/audio/{slug}/{n}", s.handlePresignAudio)
|
mux.HandleFunc("GET /api/presign/audio/{slug}/{n}", s.handlePresignAudio)
|
||||||
mux.HandleFunc("GET /api/presign/voice-sample/{voice}", s.handlePresignVoiceSample)
|
mux.HandleFunc("GET /api/presign/voice-sample/{voice}", s.handlePresignVoiceSample)
|
||||||
|
mux.HandleFunc("GET /api/presign/avatar-upload/{userId}", s.handlePresignAvatarUpload)
|
||||||
|
mux.HandleFunc("GET /api/presign/avatar/{userId}", s.handlePresignAvatar)
|
||||||
// Plain-text chapter content (used server-side for audio generation)
|
// Plain-text chapter content (used server-side for audio generation)
|
||||||
mux.HandleFunc("GET /api/chapter-text/{slug}/{n}", s.handleChapterText)
|
mux.HandleFunc("GET /api/chapter-text/{slug}/{n}", s.handleChapterText)
|
||||||
// Voices list (proxied from Kokoro)
|
// Voices list (proxied from Kokoro)
|
||||||
|
|||||||
@@ -316,6 +316,18 @@ func (h *HybridStore) PresignAudio(ctx context.Context, key string, expires time
|
|||||||
return h.minio.PresignAudio(ctx, key, expires)
|
return h.minio.PresignAudio(ctx, key, expires)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *HybridStore) PresignAvatarUpload(ctx context.Context, userID, ext string) (string, string, error) {
|
||||||
|
return h.minio.PresignAvatarUploadURL(ctx, userID, ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HybridStore) PresignAvatarURL(ctx context.Context, userID string) (string, bool, error) {
|
||||||
|
return h.minio.PresignAvatarURL(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HybridStore) DeleteAvatar(ctx context.Context, userID string) error {
|
||||||
|
return h.minio.DeleteAvatar(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Browse page snapshots ────────────────────────────────────────────────────
|
// ─── Browse page snapshots ────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *HybridStore) SaveBrowsePage(ctx context.Context, key, html string) error {
|
func (h *HybridStore) SaveBrowsePage(ctx context.Context, key, html string) error {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type MinioConfig struct {
|
|||||||
BucketChapters string // e.g. "libnovel-chapters"
|
BucketChapters string // e.g. "libnovel-chapters"
|
||||||
BucketAudio string // e.g. "libnovel-audio"
|
BucketAudio string // e.g. "libnovel-audio"
|
||||||
BucketBrowse string // e.g. "libnovel-browse"
|
BucketBrowse string // e.g. "libnovel-browse"
|
||||||
|
BucketAvatars string // e.g. "libnovel-avatars"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MinioClient wraps a minio.Client and exposes object operations for
|
// MinioClient wraps a minio.Client and exposes object operations for
|
||||||
@@ -59,7 +60,7 @@ func NewMinioClient(ctx context.Context, cfg MinioConfig) (*MinioClient, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
mc := &MinioClient{c: c, pub: pub, cfg: cfg}
|
mc := &MinioClient{c: c, pub: pub, cfg: cfg}
|
||||||
for _, bucket := range []string{cfg.BucketChapters, cfg.BucketAudio, cfg.BucketBrowse} {
|
for _, bucket := range []string{cfg.BucketChapters, cfg.BucketAudio, cfg.BucketBrowse, cfg.BucketAvatars} {
|
||||||
if bucket == "" {
|
if bucket == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -336,3 +337,77 @@ func sanitiseVoice(voice string) string {
|
|||||||
return '_'
|
return '_'
|
||||||
}, voice)
|
}, voice)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Avatar objects ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// avatarKey returns the MinIO object key for a user avatar.
|
||||||
|
// Layout: avatars/{userId}.{ext}
|
||||||
|
func avatarKey(userID, ext string) string {
|
||||||
|
return fmt.Sprintf("avatars/%s.%s", userID, ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutAvatar stores an avatar image in the avatars bucket.
|
||||||
|
// ext should be "jpg", "png", or "webp".
|
||||||
|
func (m *MinioClient) PutAvatar(ctx context.Context, userID, ext string, data []byte, contentType string) error {
|
||||||
|
if m.cfg.BucketAvatars == "" {
|
||||||
|
return fmt.Errorf("minio: avatars bucket not configured")
|
||||||
|
}
|
||||||
|
key := avatarKey(userID, ext)
|
||||||
|
_, err := m.c.PutObject(ctx, m.cfg.BucketAvatars, key,
|
||||||
|
bytes.NewReader(data), int64(len(data)),
|
||||||
|
minio.PutObjectOptions{ContentType: contentType})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("minio: put avatar %s: %w", key, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresignAvatarUploadURL returns a presigned PUT URL for uploading an avatar image
|
||||||
|
// directly to MinIO from the client. Signed with the public endpoint so iOS/browser
|
||||||
|
// can PUT bytes straight to MinIO without routing through the server.
|
||||||
|
// ext should be "jpg", "png", or "webp". Expires in 15 minutes.
|
||||||
|
func (m *MinioClient) PresignAvatarUploadURL(ctx context.Context, userID, ext string) (string, string, error) {
|
||||||
|
if m.cfg.BucketAvatars == "" {
|
||||||
|
return "", "", fmt.Errorf("minio: avatars bucket not configured")
|
||||||
|
}
|
||||||
|
key := avatarKey(userID, ext)
|
||||||
|
u, err := m.pub.PresignedPutObject(ctx, m.cfg.BucketAvatars, key, 15*time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("minio: presign avatar upload %s: %w", key, err)
|
||||||
|
}
|
||||||
|
return u.String(), key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresignAvatarURL returns a presigned GET URL for a user avatar.
|
||||||
|
// Returns ("", false, nil) when no avatar exists for the given userID.
|
||||||
|
func (m *MinioClient) PresignAvatarURL(ctx context.Context, userID string) (string, bool, error) {
|
||||||
|
if m.cfg.BucketAvatars == "" {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
// Try common extensions in order of preference.
|
||||||
|
for _, ext := range []string{"jpg", "png", "webp", "gif"} {
|
||||||
|
key := avatarKey(userID, ext)
|
||||||
|
_, statErr := m.c.StatObject(ctx, m.cfg.BucketAvatars, key, minio.StatObjectOptions{})
|
||||||
|
if statErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
u, err := m.pub.PresignedGetObject(ctx, m.cfg.BucketAvatars, key, 24*time.Hour, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("minio: presign avatar %s: %w", key, err)
|
||||||
|
}
|
||||||
|
return u.String(), true, nil
|
||||||
|
}
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAvatar removes any existing avatar for the given userID (all extensions).
|
||||||
|
func (m *MinioClient) DeleteAvatar(ctx context.Context, userID string) error {
|
||||||
|
if m.cfg.BucketAvatars == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, ext := range []string{"jpg", "png", "webp", "gif"} {
|
||||||
|
key := avatarKey(userID, ext)
|
||||||
|
_ = m.c.RemoveObject(ctx, m.cfg.BucketAvatars, key, minio.RemoveObjectOptions{})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
// started(date), finished(date), error_message(text)
|
// started(date), finished(date), error_message(text)
|
||||||
// user_sessions — user_id(text), session_id(text,unique), user_agent(text),
|
// user_sessions — user_id(text), session_id(text,unique), user_agent(text),
|
||||||
// ip(text), created_at(date), last_seen(date)
|
// ip(text), created_at(date), last_seen(date)
|
||||||
|
// book_comments — slug(text), user_id(text), username(text), body(text),
|
||||||
|
// upvotes(number), downvotes(number), created(date)
|
||||||
|
// comment_votes — comment_id(text), user_id(text), session_id(text), vote(text: up|down)
|
||||||
package storage
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -422,6 +425,29 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error {
|
|||||||
{"name": "last_seen", "type": "date"},
|
{"name": "last_seen", "type": "date"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "book_comments",
|
||||||
|
"type": "base",
|
||||||
|
"fields": []map[string]interface{}{
|
||||||
|
{"name": "slug", "type": "text", "required": true},
|
||||||
|
{"name": "user_id", "type": "text"},
|
||||||
|
{"name": "username", "type": "text"},
|
||||||
|
{"name": "body", "type": "text", "required": true},
|
||||||
|
{"name": "upvotes", "type": "number"},
|
||||||
|
{"name": "downvotes", "type": "number"},
|
||||||
|
{"name": "created", "type": "date"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "comment_votes",
|
||||||
|
"type": "base",
|
||||||
|
"fields": []map[string]interface{}{
|
||||||
|
{"name": "comment_id", "type": "text", "required": true},
|
||||||
|
{"name": "user_id", "type": "text"},
|
||||||
|
{"name": "session_id", "type": "text", "required": true},
|
||||||
|
{"name": "vote", "type": "text", "required": true}, // "up" | "down"
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, col := range collections {
|
for _, col := range collections {
|
||||||
name, _ := col["name"].(string)
|
name, _ := col["name"].(string)
|
||||||
@@ -458,6 +484,8 @@ type migration struct {
|
|||||||
var migrations = []migration{
|
var migrations = []migration{
|
||||||
// user_id was added to progress after initial deploy.
|
// user_id was added to progress after initial deploy.
|
||||||
{"progress", "user_id", "text"},
|
{"progress", "user_id", "text"},
|
||||||
|
// avatar_url stores the MinIO presign path for the user's profile picture.
|
||||||
|
{"app_users", "avatar_url", "text"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureMigrations idempotently adds any fields that are missing from existing
|
// EnsureMigrations idempotently adds any fields that are missing from existing
|
||||||
|
|||||||
@@ -160,6 +160,17 @@ type Store interface {
|
|||||||
// PresignAudio returns a presigned GET URL for an audio object.
|
// PresignAudio returns a presigned GET URL for an audio object.
|
||||||
PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error)
|
PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error)
|
||||||
|
|
||||||
|
// PresignAvatarUpload returns a short-lived presigned PUT URL for uploading
|
||||||
|
// an avatar image directly to MinIO, and the object key that will be stored.
|
||||||
|
// ext should be "jpg", "png", or "webp".
|
||||||
|
PresignAvatarUpload(ctx context.Context, userID, ext string) (uploadURL, key string, err error)
|
||||||
|
|
||||||
|
// PresignAvatarURL returns a presigned GET URL for a user's avatar, or ("", false, nil) if none.
|
||||||
|
PresignAvatarURL(ctx context.Context, userID string) (string, bool, error)
|
||||||
|
|
||||||
|
// DeleteAvatar removes all avatar objects for a user (all extensions).
|
||||||
|
DeleteAvatar(ctx context.Context, userID string) error
|
||||||
|
|
||||||
// ── Browse page snapshots (MinIO) ──────────────────────────────────────
|
// ── Browse page snapshots (MinIO) ──────────────────────────────────────
|
||||||
|
|
||||||
// SaveBrowsePage stores a SingleFile HTML snapshot for the given cache key.
|
// SaveBrowsePage stores a SingleFile HTML snapshot for the given cache key.
|
||||||
|
|||||||
13
scripts/.runner
Normal file
13
scripts/.runner
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"WARNING": "This file is automatically generated by act-runner. Do not edit it manually unless you know what you are doing. Removing this file will cause act runner to re-register as a new runner.",
|
||||||
|
"id": 11,
|
||||||
|
"uuid": "d5d04e0a-572c-46c0-83be-405508948391",
|
||||||
|
"name": "runner-mac-1",
|
||||||
|
"token": "ddf214ce148b4673a186f29cb684b407cb8c2ecc",
|
||||||
|
"address": "https://gitea.kalekber.cc/",
|
||||||
|
"labels": [
|
||||||
|
"macos-latest:host",
|
||||||
|
"macos-14:host"
|
||||||
|
],
|
||||||
|
"ephemeral": false
|
||||||
|
}
|
||||||
@@ -177,7 +177,8 @@ create_collection "app_users" '{
|
|||||||
{"name": "username", "type": "text", "required": true},
|
{"name": "username", "type": "text", "required": true},
|
||||||
{"name": "password_hash", "type": "text", "required": true},
|
{"name": "password_hash", "type": "text", "required": true},
|
||||||
{"name": "role", "type": "text"},
|
{"name": "role", "type": "text"},
|
||||||
{"name": "created", "type": "date"}
|
{"name": "created", "type": "date"},
|
||||||
|
{"name": "avatar_url", "type": "text"}
|
||||||
]
|
]
|
||||||
}'
|
}'
|
||||||
|
|
||||||
@@ -200,5 +201,31 @@ create_collection "user_settings" '{
|
|||||||
ensure_field "progress" "user_id" "text"
|
ensure_field "progress" "user_id" "text"
|
||||||
ensure_field "progress" "audio_time" "number"
|
ensure_field "progress" "audio_time" "number"
|
||||||
ensure_field "user_settings" "user_id" "text"
|
ensure_field "user_settings" "user_id" "text"
|
||||||
|
ensure_field "app_users" "avatar_url" "text"
|
||||||
|
|
||||||
|
create_collection "book_comments" '{
|
||||||
|
"name": "book_comments",
|
||||||
|
"type": "base",
|
||||||
|
"fields": [
|
||||||
|
{"name": "slug", "type": "text", "required": true},
|
||||||
|
{"name": "user_id", "type": "text"},
|
||||||
|
{"name": "username", "type": "text"},
|
||||||
|
{"name": "body", "type": "text", "required": true},
|
||||||
|
{"name": "upvotes", "type": "number"},
|
||||||
|
{"name": "downvotes", "type": "number"},
|
||||||
|
{"name": "created", "type": "date"}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
|
||||||
|
create_collection "comment_votes" '{
|
||||||
|
"name": "comment_votes",
|
||||||
|
"type": "base",
|
||||||
|
"fields": [
|
||||||
|
{"name": "comment_id", "type": "text", "required": true},
|
||||||
|
{"name": "user_id", "type": "text"},
|
||||||
|
{"name": "session_id", "type": "text", "required": true},
|
||||||
|
{"name": "vote", "type": "text", "required": true}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
|
||||||
log "all collections ready"
|
log "all collections ready"
|
||||||
|
|||||||
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"
|
||||||
39
scripts/test-ci-signing.sh
Executable file
39
scripts/test-ci-signing.sh
Executable file
@@ -0,0 +1,39 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/../ios/LibNovel"
|
||||||
|
|
||||||
|
echo "=== Testing CI-like signing process ==="
|
||||||
|
|
||||||
|
# 1. Install provisioning profile (simulate CI)
|
||||||
|
PP_PATH=~/Downloads/LibNovel_Distribution.mobileprovision
|
||||||
|
UUID=$(security cms -D -i "$PP_PATH" | plutil -extract UUID raw -)
|
||||||
|
PROFILE_NAME=$(security cms -D -i "$PP_PATH" | plutil -extract Name raw -)
|
||||||
|
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||||
|
cp "$PP_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
|
||||||
|
|
||||||
|
echo "Installed profile: $PROFILE_NAME (UUID: $UUID)"
|
||||||
|
|
||||||
|
# 2. Generate Xcode project
|
||||||
|
echo "Generating Xcode project..."
|
||||||
|
xcodegen generate --spec project.yml --project .
|
||||||
|
|
||||||
|
# 3. List available provisioning profiles
|
||||||
|
echo -e "\n=== Available provisioning profiles ==="
|
||||||
|
ls -la ~/Library/MobileDevice/Provisioning\ Profiles/
|
||||||
|
|
||||||
|
# 4. Try building with xcodebuild using manual signing
|
||||||
|
echo -e "\n=== Attempting archive with manual signing ==="
|
||||||
|
xcodebuild archive \
|
||||||
|
-scheme LibNovel \
|
||||||
|
-project LibNovel.xcodeproj \
|
||||||
|
-configuration Release \
|
||||||
|
-destination 'generic/platform=iOS' \
|
||||||
|
-archivePath /tmp/LibNovel.xcarchive \
|
||||||
|
CODE_SIGN_STYLE=Manual \
|
||||||
|
CODE_SIGN_IDENTITY="Apple Distribution: Kamil Alekberov (GHZXC6FVMU)" \
|
||||||
|
DEVELOPMENT_TEAM=GHZXC6FVMU \
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER="$UUID" \
|
||||||
|
| xcpretty || true
|
||||||
|
|
||||||
|
echo -e "\n=== Build complete ==="
|
||||||
53
scripts/test-ios-build-simple.sh
Executable file
53
scripts/test-ios-build-simple.sh
Executable file
@@ -0,0 +1,53 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Simple iOS build test without fastlane
|
||||||
|
# Run from project root: ./scripts/test-ios-build-simple.sh /path/to/profile.mobileprovision
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
PROFILE_PATH="$1"
|
||||||
|
|
||||||
|
if [ -z "$PROFILE_PATH" ]; then
|
||||||
|
echo "Usage: $0 /path/to/profile.mobileprovision"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Step 1: Extract profile info ==="
|
||||||
|
UUID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract UUID raw -)
|
||||||
|
PROFILE_NAME=$(security cms -D -i "$PROFILE_PATH" | plutil -extract Name raw -)
|
||||||
|
TEAM_ID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract TeamIdentifier.0 raw -)
|
||||||
|
|
||||||
|
echo "Profile Name: $PROFILE_NAME"
|
||||||
|
echo "UUID: $UUID"
|
||||||
|
echo "Team ID: $TEAM_ID"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Step 2: Install profile ==="
|
||||||
|
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||||
|
cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
|
||||||
|
echo "✓ Installed"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Step 3: Check signing identities ==="
|
||||||
|
security find-identity -v -p codesigning
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Step 4: Generate Xcode project ==="
|
||||||
|
cd ios/LibNovel
|
||||||
|
export USER=runner
|
||||||
|
xcodegen generate --spec project.yml --project .
|
||||||
|
echo "✓ Project generated"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Step 5: Try automatic signing build ==="
|
||||||
|
xcodebuild archive \
|
||||||
|
-project LibNovel.xcodeproj \
|
||||||
|
-scheme LibNovel \
|
||||||
|
-configuration Release \
|
||||||
|
-destination 'generic/platform=iOS' \
|
||||||
|
-archivePath ./build/LibNovel.xcarchive \
|
||||||
|
-allowProvisioningUpdates \
|
||||||
|
CODE_SIGN_STYLE=Automatic \
|
||||||
|
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== ✓ BUILD SUCCEEDED! ==="
|
||||||
58
scripts/test-ios-build.sh
Executable file
58
scripts/test-ios-build.sh
Executable file
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Local build test script
|
||||||
|
# Run from the project root: ./scripts/test-ios-build.sh /path/to/your/profile.mobileprovision
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
PROFILE_PATH="$1"
|
||||||
|
|
||||||
|
if [ -z "$PROFILE_PATH" ]; then
|
||||||
|
echo "Usage: $0 /path/to/profile.mobileprovision"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$PROFILE_PATH" ]; then
|
||||||
|
echo "Error: Profile not found at $PROFILE_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Extracting profile info ==="
|
||||||
|
UUID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract UUID raw -)
|
||||||
|
PROFILE_NAME=$(security cms -D -i "$PROFILE_PATH" | plutil -extract Name raw -)
|
||||||
|
BUNDLE_ID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract Entitlements.application-identifier raw - 2>/dev/null | sed 's/.*\.//')
|
||||||
|
TEAM_ID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract TeamIdentifier.0 raw -)
|
||||||
|
|
||||||
|
echo "Profile Name: $PROFILE_NAME"
|
||||||
|
echo "UUID: $UUID"
|
||||||
|
echo "Bundle ID: $BUNDLE_ID"
|
||||||
|
echo "Team ID: $TEAM_ID"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Installing provisioning profile ==="
|
||||||
|
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||||
|
cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
|
||||||
|
echo "Installed to: ~/Library/MobileDevice/Provisioning Profiles/$UUID.mobileprovision"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Listing signing identities ==="
|
||||||
|
security find-identity -v -p codesigning
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Navigating to iOS project ==="
|
||||||
|
cd ios/LibNovel
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Generating Xcode project ==="
|
||||||
|
xcodegen generate --spec project.yml --project .
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Testing fastlane build ==="
|
||||||
|
export USER=runner
|
||||||
|
export BUILD_NUMBER=999
|
||||||
|
export PROVISIONING_PROFILE_NAME="$PROFILE_NAME"
|
||||||
|
|
||||||
|
# Run fastlane beta lane
|
||||||
|
fastlane beta --verbose
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Build succeeded! ==="
|
||||||
@@ -8,16 +8,11 @@ COPY . .
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ── Runtime image ──────────────────────────────────────────────────────────────
|
# ── Runtime image ──────────────────────────────────────────────────────────────
|
||||||
|
# adapter-node bundles all dependencies into build/ — no npm install needed.
|
||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# adapter-node produces a standalone build/
|
|
||||||
COPY --from=builder /app/build ./build
|
COPY --from=builder /app/build ./build
|
||||||
COPY --from=builder /app/package.json ./
|
|
||||||
COPY --from=builder /app/package-lock.json ./
|
|
||||||
|
|
||||||
# Install production dependencies (e.g. marked) that are imported at runtime
|
|
||||||
RUN npm ci --omit=dev
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
@@ -25,3 +20,4 @@ ENV HOST=0.0.0.0
|
|||||||
|
|
||||||
EXPOSE $PORT
|
EXPOSE $PORT
|
||||||
CMD ["node", "build"]
|
CMD ["node", "build"]
|
||||||
|
|
||||||
|
|||||||
1680
ui/package-lock.json
generated
1680
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,9 @@
|
|||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1005.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.1005.0",
|
||||||
|
"cropperjs": "^1.6.2",
|
||||||
"marked": "^17.0.3",
|
"marked": "^17.0.3",
|
||||||
"pocketbase": "^0.26.8"
|
"pocketbase": "^0.26.8"
|
||||||
}
|
}
|
||||||
|
|||||||
92
ui/src/lib/components/AvatarCropModal.svelte
Normal file
92
ui/src/lib/components/AvatarCropModal.svelte
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import Cropper from 'cropperjs';
|
||||||
|
import 'cropperjs/dist/cropper.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
file: File;
|
||||||
|
onconfirm: (blob: Blob, mimeType: string) => void;
|
||||||
|
oncancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { file, onconfirm, oncancel }: Props = $props();
|
||||||
|
|
||||||
|
let imgEl: HTMLImageElement;
|
||||||
|
let cropper: Cropper | null = null;
|
||||||
|
let objectUrl = $state('');
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
objectUrl = URL.createObjectURL(file);
|
||||||
|
cropper = new Cropper(imgEl, {
|
||||||
|
aspectRatio: 1,
|
||||||
|
viewMode: 1,
|
||||||
|
dragMode: 'move',
|
||||||
|
autoCropArea: 0.8,
|
||||||
|
restore: false,
|
||||||
|
guides: false,
|
||||||
|
center: true,
|
||||||
|
highlight: false,
|
||||||
|
cropBoxMovable: true,
|
||||||
|
cropBoxResizable: true,
|
||||||
|
toggleDragModeOnDblclick: false,
|
||||||
|
background: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
cropper?.destroy();
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
if (!cropper) return;
|
||||||
|
const canvas = cropper.getCroppedCanvas({ width: 400, height: 400 });
|
||||||
|
const mimeType = file.type === 'image/webp' ? 'image/webp' : 'image/jpeg';
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob: Blob | null) => {
|
||||||
|
if (blob) onconfirm(blob, mimeType);
|
||||||
|
},
|
||||||
|
mimeType,
|
||||||
|
0.9
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Crop profile picture"
|
||||||
|
>
|
||||||
|
<div class="bg-zinc-900 rounded-2xl border border-zinc-700 shadow-2xl w-full max-w-sm flex flex-col gap-4 p-5">
|
||||||
|
<h2 class="text-base font-semibold text-zinc-100">Crop profile picture</h2>
|
||||||
|
|
||||||
|
<!-- Cropper image container -->
|
||||||
|
<div class="rounded-xl overflow-hidden bg-zinc-800" style="max-height: 340px;">
|
||||||
|
<img
|
||||||
|
bind:this={imgEl}
|
||||||
|
src={objectUrl}
|
||||||
|
alt="Crop preview"
|
||||||
|
style="display:block; max-width:100%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-zinc-500 text-center">Drag to reposition · pinch or scroll to zoom · drag corners to resize</p>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
onclick={oncancel}
|
||||||
|
class="flex-1 py-2 rounded-lg border border-zinc-600 text-zinc-300 text-sm font-medium hover:bg-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={confirm}
|
||||||
|
class="flex-1 py-2 rounded-lg bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
Use photo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
254
ui/src/lib/components/CommentsSection.svelte
Normal file
254
ui/src/lib/components/CommentsSection.svelte
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface BookComment {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
user_id: string;
|
||||||
|
username: string;
|
||||||
|
body: string;
|
||||||
|
upvotes: number;
|
||||||
|
downvotes: number;
|
||||||
|
created: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
slug,
|
||||||
|
isLoggedIn = false
|
||||||
|
}: {
|
||||||
|
slug: string;
|
||||||
|
isLoggedIn?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// ── State ─────────────────────────────────────────────────────────────────
|
||||||
|
let comments = $state<BookComment[]>([]);
|
||||||
|
let myVotes = $state<Record<string, 'up' | 'down'>>({});
|
||||||
|
let loading = $state(true);
|
||||||
|
let loadError = $state('');
|
||||||
|
|
||||||
|
let newBody = $state('');
|
||||||
|
let posting = $state(false);
|
||||||
|
let postError = $state('');
|
||||||
|
|
||||||
|
// Per-comment vote inflight set (prevents double-clicks)
|
||||||
|
let votingIds = $state(new Set<string>());
|
||||||
|
|
||||||
|
// ── Load comments on mount ────────────────────────────────────────────────
|
||||||
|
async function loadComments() {
|
||||||
|
loading = true;
|
||||||
|
loadError = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`);
|
||||||
|
if (!res.ok) throw new Error(`${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
comments = data.comments ?? [];
|
||||||
|
myVotes = data.myVotes ?? {};
|
||||||
|
} catch (e) {
|
||||||
|
loadError = 'Failed to load comments.';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run once on component mount via $effect
|
||||||
|
$effect(() => {
|
||||||
|
loadComments();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Post comment ──────────────────────────────────────────────────────────
|
||||||
|
async function postComment() {
|
||||||
|
const text = newBody.trim();
|
||||||
|
if (!text || posting) return;
|
||||||
|
if (text.length > 2000) {
|
||||||
|
postError = 'Comment is too long (max 2000 characters).';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
posting = true;
|
||||||
|
postError = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ body: text })
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
postError = 'You must be logged in to comment.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
postError = err.message ?? 'Failed to post comment.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const created: BookComment = await res.json();
|
||||||
|
comments = [created, ...comments];
|
||||||
|
newBody = '';
|
||||||
|
} catch {
|
||||||
|
postError = 'Failed to post comment.';
|
||||||
|
} finally {
|
||||||
|
posting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vote ──────────────────────────────────────────────────────────────────
|
||||||
|
async function vote(commentId: string, v: 'up' | 'down') {
|
||||||
|
if (votingIds.has(commentId)) return;
|
||||||
|
votingIds = new Set([...votingIds, commentId]);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/comments/${commentId}/vote`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ vote: v })
|
||||||
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
|
const updated: BookComment = await res.json();
|
||||||
|
// Update comment in list
|
||||||
|
comments = comments.map((c) => (c.id === commentId ? updated : c));
|
||||||
|
// Update myVotes: toggle off if same, else set new vote
|
||||||
|
const prev = myVotes[commentId];
|
||||||
|
if (prev === v) {
|
||||||
|
const next = { ...myVotes };
|
||||||
|
delete next[commentId];
|
||||||
|
myVotes = next;
|
||||||
|
} else {
|
||||||
|
myVotes = { ...myVotes, [commentId]: v };
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
const next = new Set(votingIds);
|
||||||
|
next.delete(commentId);
|
||||||
|
votingIds = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const charCount = $derived(newBody.length);
|
||||||
|
const charOver = $derived(charCount > 2000);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mt-10">
|
||||||
|
<h2 class="text-base font-semibold text-zinc-200 mb-4">
|
||||||
|
Comments
|
||||||
|
{#if !loading && comments.length > 0}
|
||||||
|
<span class="text-zinc-500 font-normal text-sm ml-1">({comments.length})</span>
|
||||||
|
{/if}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- Post form -->
|
||||||
|
<div class="mb-6">
|
||||||
|
{#if isLoggedIn}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<textarea
|
||||||
|
bind:value={newBody}
|
||||||
|
placeholder="Write a comment…"
|
||||||
|
rows="3"
|
||||||
|
class="w-full px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-zinc-200 text-sm placeholder-zinc-500 resize-none focus:outline-none focus:border-amber-400 transition-colors"
|
||||||
|
></textarea>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<span class="text-xs {charOver ? 'text-red-400' : 'text-zinc-600'} tabular-nums">
|
||||||
|
{charCount}/2000
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{#if postError}
|
||||||
|
<span class="text-xs text-red-400">{postError}</span>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
onclick={postComment}
|
||||||
|
disabled={posting || !newBody.trim() || charOver}
|
||||||
|
class="px-4 py-1.5 rounded-lg text-sm font-medium transition-colors
|
||||||
|
{posting || !newBody.trim() || charOver
|
||||||
|
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
|
||||||
|
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300'}"
|
||||||
|
>
|
||||||
|
{posting ? 'Posting…' : 'Post'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-zinc-500">
|
||||||
|
<a href="/auth/login" class="text-amber-400 hover:text-amber-300 transition-colors">Log in</a>
|
||||||
|
to leave a comment.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comment list -->
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#each Array(3) as _}
|
||||||
|
<div class="rounded-lg bg-zinc-800/50 p-4 animate-pulse">
|
||||||
|
<div class="h-3 w-24 bg-zinc-700 rounded mb-3"></div>
|
||||||
|
<div class="h-3 w-full bg-zinc-700/60 rounded mb-2"></div>
|
||||||
|
<div class="h-3 w-3/4 bg-zinc-700/60 rounded"></div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if loadError}
|
||||||
|
<p class="text-sm text-red-400">{loadError}</p>
|
||||||
|
{:else if comments.length === 0}
|
||||||
|
<p class="text-sm text-zinc-500">No comments yet. Be the first!</p>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#each comments as comment (comment.id)}
|
||||||
|
{@const myVote = myVotes[comment.id]}
|
||||||
|
{@const voting = votingIds.has(comment.id)}
|
||||||
|
<div class="rounded-lg bg-zinc-800/50 border border-zinc-700/50 px-4 py-3 flex flex-col gap-2">
|
||||||
|
<!-- Header: username + date -->
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<span class="text-sm font-medium text-zinc-200">{comment.username || 'Anonymous'}</span>
|
||||||
|
<span class="text-zinc-600 text-xs">·</span>
|
||||||
|
<span class="text-xs text-zinc-500">{formatDate(comment.created)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
|
||||||
|
|
||||||
|
<!-- Vote row -->
|
||||||
|
<div class="flex items-center gap-3 pt-1">
|
||||||
|
<!-- Upvote -->
|
||||||
|
<button
|
||||||
|
onclick={() => vote(comment.id, 'up')}
|
||||||
|
disabled={voting}
|
||||||
|
title="Upvote"
|
||||||
|
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||||
|
{myVote === 'up'
|
||||||
|
? 'text-amber-400'
|
||||||
|
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
|
||||||
|
</svg>
|
||||||
|
<span class="tabular-nums">{comment.upvotes ?? 0}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Downvote -->
|
||||||
|
<button
|
||||||
|
onclick={() => vote(comment.id, 'down')}
|
||||||
|
disabled={voting}
|
||||||
|
title="Downvote"
|
||||||
|
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||||
|
{myVote === 'down'
|
||||||
|
? 'text-red-400'
|
||||||
|
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
|
||||||
|
</svg>
|
||||||
|
<span class="tabular-nums">{comment.downvotes ?? 0}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -15,6 +15,46 @@ const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
|||||||
// In docker-compose this would differ from the internal endpoint.
|
// In docker-compose this would differ from the internal endpoint.
|
||||||
const MINIO_PUBLIC_URL = pubEnv.PUBLIC_MINIO_PUBLIC_URL ?? 'http://localhost:9000';
|
const MINIO_PUBLIC_URL = pubEnv.PUBLIC_MINIO_PUBLIC_URL ?? 'http://localhost:9000';
|
||||||
|
|
||||||
|
// ─── Avatar helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extFromMime(mime: string): string {
|
||||||
|
if (mime.includes('png')) return 'png';
|
||||||
|
if (mime.includes('webp')) return 'webp';
|
||||||
|
if (mime.includes('gif')) return 'gif';
|
||||||
|
return 'jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a short-lived presigned PUT URL for uploading an avatar directly to MinIO,
|
||||||
|
* along with the object key to record in PocketBase after upload completes.
|
||||||
|
* Routed through the Go scraper which holds MinIO credentials.
|
||||||
|
*/
|
||||||
|
export async function presignAvatarUploadUrl(userId: string, mimeType: string): Promise<{ uploadUrl: string; key: string }> {
|
||||||
|
const ext = extFromMime(mimeType);
|
||||||
|
const res = await fetch(`${SCRAPER_URL}/api/presign/avatar-upload/${encodeURIComponent(userId)}?ext=${ext}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
throw new Error(`presign avatar upload failed: ${res.status} ${body}`);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { upload_url: string; key: string };
|
||||||
|
return { uploadUrl: data.upload_url, key: data.key };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a presigned GET URL for a user's avatar, rewritten to the public URL.
|
||||||
|
* Returns null if no avatar exists.
|
||||||
|
*/
|
||||||
|
export async function presignAvatarUrl(userId: string): Promise<string | null> {
|
||||||
|
const res = await fetch(`${SCRAPER_URL}/api/presign/avatar/${encodeURIComponent(userId)}`);
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
throw new Error(`presign avatar failed: ${res.status} ${body}`);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { url: string };
|
||||||
|
return data.url ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rewrites the MinIO host in a presigned URL to the public-facing URL.
|
* Rewrites the MinIO host in a presigned URL to the public-facing URL.
|
||||||
* The presigned URL is signed against the internal endpoint (e.g. minio:9000),
|
* The presigned URL is signed against the internal endpoint (e.g. minio:9000),
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export interface User {
|
|||||||
password_hash: string;
|
password_hash: string;
|
||||||
role: string;
|
role: string;
|
||||||
created: string;
|
created: string;
|
||||||
|
avatar_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Auth token cache ─────────────────────────────────────────────────────────
|
// ─── Auth token cache ─────────────────────────────────────────────────────────
|
||||||
@@ -794,3 +795,200 @@ export async function revokeAllUserSessions(userId: string): Promise<void> {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the avatar_url field for a user record.
|
||||||
|
*/
|
||||||
|
export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Promise<void> {
|
||||||
|
const token = await getToken();
|
||||||
|
const res = await fetch(`${PB_URL}/api/collections/app_users/records/${userId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ avatar_url: avatarUrl })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
throw new Error(`updateUserAvatarUrl failed: ${res.status} ${body}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Comments ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface BookComment {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
user_id: string;
|
||||||
|
username: string;
|
||||||
|
body: string;
|
||||||
|
upvotes: number;
|
||||||
|
downvotes: number;
|
||||||
|
created: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentVote {
|
||||||
|
id: string;
|
||||||
|
comment_id: string;
|
||||||
|
user_id: string;
|
||||||
|
session_id: string;
|
||||||
|
vote: 'up' | 'down';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List comments for a book, newest first, up to 100.
|
||||||
|
*/
|
||||||
|
export async function listComments(slug: string): Promise<BookComment[]> {
|
||||||
|
const token = await getToken();
|
||||||
|
const filter = encodeURIComponent(`slug="${slug.replace(/"/g, '\\"')}"`);
|
||||||
|
const res = await fetch(
|
||||||
|
`${PB_URL}/api/collections/book_comments/records?filter=${filter}&sort=-created&perPage=100`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return (data.items ?? []) as BookComment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new comment. Returns the created record.
|
||||||
|
*/
|
||||||
|
export async function createComment(
|
||||||
|
slug: string,
|
||||||
|
body: string,
|
||||||
|
userId: string | undefined,
|
||||||
|
username: string
|
||||||
|
): Promise<BookComment> {
|
||||||
|
const token = await getToken();
|
||||||
|
const res = await fetch(`${PB_URL}/api/collections/book_comments/records`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug,
|
||||||
|
body,
|
||||||
|
user_id: userId ?? '',
|
||||||
|
username,
|
||||||
|
upvotes: 0,
|
||||||
|
downvotes: 0,
|
||||||
|
created: new Date().toISOString()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`createComment failed: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<BookComment>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an existing vote by this voter (identified by user_id or session_id) on a comment.
|
||||||
|
*/
|
||||||
|
export async function getCommentVote(
|
||||||
|
commentId: string,
|
||||||
|
sessionId: string,
|
||||||
|
userId?: string
|
||||||
|
): Promise<CommentVote | null> {
|
||||||
|
const token = await getToken();
|
||||||
|
const voterFilter = userId
|
||||||
|
? `comment_id="${commentId}"&&user_id="${userId}"`
|
||||||
|
: `comment_id="${commentId}"&&session_id="${sessionId}"`;
|
||||||
|
const res = await fetch(
|
||||||
|
`${PB_URL}/api/collections/comment_votes/records?filter=${encodeURIComponent(voterFilter)}&perPage=1`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
const items = (data.items ?? []) as CommentVote[];
|
||||||
|
return items[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cast or change a vote on a comment. Handles:
|
||||||
|
* - New vote: creates vote record, increments counter.
|
||||||
|
* - Same vote again: removes it (toggle off), decrements counter.
|
||||||
|
* - Changed vote: updates record, adjusts both counters.
|
||||||
|
* Returns the updated comment.
|
||||||
|
*/
|
||||||
|
export async function voteComment(
|
||||||
|
commentId: string,
|
||||||
|
vote: 'up' | 'down',
|
||||||
|
sessionId: string,
|
||||||
|
userId?: string
|
||||||
|
): Promise<BookComment> {
|
||||||
|
const token = await getToken();
|
||||||
|
|
||||||
|
// Fetch current comment
|
||||||
|
const commentRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (!commentRes.ok) throw new Error(`Comment not found: ${commentId}`);
|
||||||
|
const comment = (await commentRes.json()) as BookComment;
|
||||||
|
|
||||||
|
const existing = await getCommentVote(commentId, sessionId, userId);
|
||||||
|
|
||||||
|
let upDelta = 0;
|
||||||
|
let downDelta = 0;
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
// New vote
|
||||||
|
await fetch(`${PB_URL}/api/collections/comment_votes/records`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ comment_id: commentId, user_id: userId ?? '', session_id: sessionId, vote })
|
||||||
|
});
|
||||||
|
vote === 'up' ? upDelta++ : downDelta++;
|
||||||
|
} else if (existing.vote === vote) {
|
||||||
|
// Toggle off — remove vote
|
||||||
|
await fetch(`${PB_URL}/api/collections/comment_votes/records/${existing.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
vote === 'up' ? upDelta-- : downDelta--;
|
||||||
|
} else {
|
||||||
|
// Changed vote
|
||||||
|
await fetch(`${PB_URL}/api/collections/comment_votes/records/${existing.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ vote })
|
||||||
|
});
|
||||||
|
if (vote === 'up') { upDelta++; downDelta--; }
|
||||||
|
else { upDelta--; downDelta++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch comment counters
|
||||||
|
const patchRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
upvotes: Math.max(0, (comment.upvotes ?? 0) + upDelta),
|
||||||
|
downvotes: Math.max(0, (comment.downvotes ?? 0) + downDelta)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!patchRes.ok) throw new Error(`Failed to update vote counts on comment ${commentId}`);
|
||||||
|
return patchRes.json() as Promise<BookComment>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch votes cast by this session/user, keyed by comment_id.
|
||||||
|
* Returns a map of commentId → 'up' | 'down'.
|
||||||
|
*/
|
||||||
|
export async function getMyVotes(
|
||||||
|
commentIds: string[],
|
||||||
|
sessionId: string,
|
||||||
|
userId?: string
|
||||||
|
): Promise<Record<string, 'up' | 'down'>> {
|
||||||
|
if (commentIds.length === 0) return {};
|
||||||
|
const token = await getToken();
|
||||||
|
const idFilter = commentIds.map((id) => `comment_id="${id}"`).join('||');
|
||||||
|
const voterPart = userId ? `user_id="${userId}"` : `session_id="${sessionId}"`;
|
||||||
|
const filter = encodeURIComponent(`(${idFilter})&&${voterPart}`);
|
||||||
|
const res = await fetch(
|
||||||
|
`${PB_URL}/api/collections/comment_votes/records?filter=${filter}&perPage=200`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
);
|
||||||
|
if (!res.ok) return {};
|
||||||
|
const data = await res.json();
|
||||||
|
const map: Record<string, 'up' | 'down'> = {};
|
||||||
|
for (const v of (data.items ?? []) as CommentVote[]) {
|
||||||
|
map[v.comment_id] = v.vote as 'up' | 'down';
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
import { getUserByUsername } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/auth/me
|
* GET /api/auth/me
|
||||||
@@ -10,10 +11,12 @@ export const GET: RequestHandler = async ({ locals }) => {
|
|||||||
if (!locals.user) {
|
if (!locals.user) {
|
||||||
error(401, 'Not authenticated');
|
error(401, 'Not authenticated');
|
||||||
}
|
}
|
||||||
|
// Fetch full record from PocketBase to get avatar_url
|
||||||
|
const record = await getUserByUsername(locals.user.username).catch(() => null);
|
||||||
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 ?? ''
|
avatar_url: record?.avatar_url ?? null
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
|
|||||||
const res = await fetch(presignUrl);
|
const res = await fetch(presignUrl);
|
||||||
if (!res.ok) throw new Error(`MinIO returned ${res.status}`);
|
if (!res.ok) throw new Error(`MinIO returned ${res.status}`);
|
||||||
const markdown = await res.text();
|
const markdown = await res.text();
|
||||||
html = await marked(markdown, { async: true });
|
html = marked(markdown) as string;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error('api/chapter', 'failed to fetch chapter content', { slug, n, err: String(e) });
|
log.error('api/chapter', 'failed to fetch chapter content', { slug, n, err: String(e) });
|
||||||
}
|
}
|
||||||
|
|||||||
33
ui/src/routes/api/comments/[id]/vote/+server.ts
Normal file
33
ui/src/routes/api/comments/[id]/vote/+server.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { voteComment } from '$lib/server/pocketbase';
|
||||||
|
import { log } from '$lib/server/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/comments/[id]/vote
|
||||||
|
* Body: { vote: 'up' | 'down' }
|
||||||
|
* Casts, changes, or toggles off a vote on a comment.
|
||||||
|
* Works for both authenticated and anonymous users (session-scoped).
|
||||||
|
* Returns the updated comment.
|
||||||
|
*/
|
||||||
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
const { id } = params;
|
||||||
|
let body: { vote?: string };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
error(400, 'Invalid JSON body');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.vote !== 'up' && body.vote !== 'down') {
|
||||||
|
error(400, 'vote must be "up" or "down"');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await voteComment(id, body.vote, locals.sessionId, locals.user?.id);
|
||||||
|
return json(updated);
|
||||||
|
} catch (e) {
|
||||||
|
log.error('api/comments/[id]/vote', 'voteComment failed', { id, err: String(e) });
|
||||||
|
error(500, 'Failed to record vote');
|
||||||
|
}
|
||||||
|
};
|
||||||
54
ui/src/routes/api/comments/[slug]/+server.ts
Normal file
54
ui/src/routes/api/comments/[slug]/+server.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { listComments, createComment, getMyVotes } from '$lib/server/pocketbase';
|
||||||
|
import { log } from '$lib/server/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/comments/[slug]
|
||||||
|
* Returns comments for a book + the current visitor's votes.
|
||||||
|
* Response: { comments: BookComment[], myVotes: Record<string, 'up'|'down'> }
|
||||||
|
*/
|
||||||
|
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||||
|
const { slug } = params;
|
||||||
|
try {
|
||||||
|
const comments = await listComments(slug);
|
||||||
|
const myVotes = await getMyVotes(
|
||||||
|
comments.map((c) => c.id),
|
||||||
|
locals.sessionId,
|
||||||
|
locals.user?.id
|
||||||
|
);
|
||||||
|
return json({ comments, myVotes });
|
||||||
|
} catch (e) {
|
||||||
|
log.error('api/comments/[slug]', 'listComments failed', { slug, err: String(e) });
|
||||||
|
error(500, 'Failed to load comments');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/comments/[slug]
|
||||||
|
* Body: { body: string }
|
||||||
|
* Creates a new comment. Requires authentication.
|
||||||
|
*/
|
||||||
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Login required to comment');
|
||||||
|
|
||||||
|
const { slug } = params;
|
||||||
|
let body: { body?: string };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
error(400, 'Invalid JSON body');
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = (body.body ?? '').trim();
|
||||||
|
if (!text) error(400, 'Comment body is required');
|
||||||
|
if (text.length > 2000) error(400, 'Comment is too long (max 2000 characters)');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const comment = await createComment(slug, text, locals.user.id, locals.user.username);
|
||||||
|
return json(comment, { status: 201 });
|
||||||
|
} catch (e) {
|
||||||
|
log.error('api/comments/[slug]', 'createComment failed', { slug, err: String(e) });
|
||||||
|
error(500, 'Failed to post comment');
|
||||||
|
}
|
||||||
|
};
|
||||||
81
ui/src/routes/api/profile/avatar/+server.ts
Normal file
81
ui/src/routes/api/profile/avatar/+server.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { presignAvatarUploadUrl, presignAvatarUrl } from '$lib/server/minio';
|
||||||
|
import { updateUserAvatarUrl, getUserByUsername } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
|
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/profile/avatar
|
||||||
|
* Body: JSON { mime_type: "image/jpeg" | "image/png" | "image/webp" }
|
||||||
|
*
|
||||||
|
* Returns a short-lived presigned PUT URL pointing at MinIO (public endpoint)
|
||||||
|
* so the client can upload the image bytes directly, bypassing the server.
|
||||||
|
* After the PUT completes, the client must call PATCH /api/profile/avatar
|
||||||
|
* with the returned key to record it in PocketBase.
|
||||||
|
*
|
||||||
|
* Returns: { upload_url: string, key: string }
|
||||||
|
*/
|
||||||
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not authenticated');
|
||||||
|
|
||||||
|
let mimeType = 'image/jpeg';
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
if (body?.mime_type) mimeType = body.mime_type;
|
||||||
|
} catch {
|
||||||
|
// default to jpeg if body is missing/invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_TYPES.includes(mimeType)) {
|
||||||
|
error(400, `Unsupported image type: ${mimeType}. Allowed: jpeg, png, webp`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { uploadUrl, key } = await presignAvatarUploadUrl(locals.user.id, mimeType);
|
||||||
|
return json({ upload_url: uploadUrl, key });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/profile/avatar
|
||||||
|
* Body: JSON { key: string }
|
||||||
|
*
|
||||||
|
* Called after the client has successfully PUT the image to MinIO via the
|
||||||
|
* presigned URL. Records the object key in PocketBase and returns a fresh
|
||||||
|
* presigned GET URL for immediate display.
|
||||||
|
*
|
||||||
|
* Returns: { avatar_url: string | null }
|
||||||
|
*/
|
||||||
|
export const PATCH: RequestHandler = async ({ request, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not authenticated');
|
||||||
|
|
||||||
|
let key: string | undefined;
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
if (typeof body?.key === 'string') key = body.key;
|
||||||
|
} catch {
|
||||||
|
error(400, 'Invalid JSON body');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!key) error(400, 'Missing "key" field');
|
||||||
|
|
||||||
|
await updateUserAvatarUrl(locals.user.id, key);
|
||||||
|
|
||||||
|
const avatarUrl = await presignAvatarUrl(locals.user.id);
|
||||||
|
return json({ avatar_url: avatarUrl });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/profile/avatar
|
||||||
|
* Returns a presigned GET URL for the current user's avatar, or null if none set.
|
||||||
|
*/
|
||||||
|
export const GET: RequestHandler = async ({ locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not authenticated');
|
||||||
|
|
||||||
|
const record = await getUserByUsername(locals.user.username).catch(() => null);
|
||||||
|
if (!record?.avatar_url) {
|
||||||
|
return json({ avatar_url: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarUrl = await presignAvatarUrl(locals.user.id);
|
||||||
|
return json({ avatar_url: avatarUrl });
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
import CommentsSection from '$lib/components/CommentsSection.svelte';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
@@ -525,3 +526,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════ Comments ══ -->
|
||||||
|
<CommentsSection slug={data.book.slug} isLoggedIn={true} />
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
|||||||
const res = await fetch(presignUrl);
|
const res = await fetch(presignUrl);
|
||||||
if (!res.ok) throw new Error(`MinIO returned ${res.status}`);
|
if (!res.ok) throw new Error(`MinIO returned ${res.status}`);
|
||||||
const markdown = await res.text();
|
const markdown = await res.text();
|
||||||
html = await marked(markdown, { async: true });
|
html = marked(markdown) as string;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Don't hard-fail — show empty content with error message
|
// Don't hard-fail — show empty content with error message
|
||||||
log.error('chapter', 'failed to fetch chapter content', { slug, n, err: String(e) });
|
log.error('chapter', 'failed to fetch chapter content', { slug, n, err: String(e) });
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
|
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { marked } from 'marked';
|
|
||||||
|
|
||||||
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 +18,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) {
|
||||||
@@ -42,6 +41,7 @@
|
|||||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||||
const d = (await res.json()) as { text?: string };
|
const d = (await res.json()) as { text?: string };
|
||||||
if (d.text) {
|
if (d.text) {
|
||||||
|
const { marked } = await import('marked');
|
||||||
html = await marked(d.text, { async: true });
|
html = await marked(d.text, { async: true });
|
||||||
} else {
|
} else {
|
||||||
fetchError = 'Chapter content not available.';
|
fetchError = 'Chapter content not available.';
|
||||||
|
|||||||
6
ui/src/routes/health/+server.ts
Normal file
6
ui/src/routes/health/+server.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = () => {
|
||||||
|
return json({ status: 'ok' });
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { changePassword, listUserSessions } from '$lib/server/pocketbase';
|
import { changePassword, listUserSessions, getUserByUsername } from '$lib/server/pocketbase';
|
||||||
|
import { presignAvatarUrl } from '$lib/server/minio';
|
||||||
import { log } from '$lib/server/logger';
|
import { log } from '$lib/server/logger';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
@@ -15,8 +16,20 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) });
|
log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch avatar presigned URL if user has one
|
||||||
|
let avatarUrl: string | null = null;
|
||||||
|
try {
|
||||||
|
const record = await getUserByUsername(locals.user.username);
|
||||||
|
if (record?.avatar_url) {
|
||||||
|
avatarUrl = await presignAvatarUrl(locals.user.id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(e) });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: locals.user,
|
user: locals.user,
|
||||||
|
avatarUrl,
|
||||||
sessions: sessions.map((s) => ({
|
sessions: sessions.map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
user_agent: s.user_agent,
|
user_agent: s.user_agent,
|
||||||
|
|||||||
@@ -3,9 +3,81 @@
|
|||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
import type { PageData, ActionData } from './$types';
|
import type { PageData, ActionData } from './$types';
|
||||||
import { audioStore } from '$lib/audio.svelte';
|
import { audioStore } from '$lib/audio.svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
|
// ── Avatar ───────────────────────────────────────────────────────────────────
|
||||||
|
let avatarUrl = $state<string | null>(data.avatarUrl ?? null);
|
||||||
|
let avatarUploading = $state(false);
|
||||||
|
let avatarError = $state('');
|
||||||
|
let fileInput: HTMLInputElement | null = null;
|
||||||
|
|
||||||
|
// Crop modal state
|
||||||
|
let cropFile = $state<File | null>(null);
|
||||||
|
|
||||||
|
function handleAvatarChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
// Reset input so the same file can be re-selected after cancel
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
cropFile = file;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCropConfirm(blob: Blob, mimeType: string) {
|
||||||
|
cropFile = null;
|
||||||
|
avatarUploading = true;
|
||||||
|
avatarError = '';
|
||||||
|
try {
|
||||||
|
// Step 1: get presigned PUT URL
|
||||||
|
const presignRes = await fetch('/api/profile/avatar', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mime_type: mimeType })
|
||||||
|
});
|
||||||
|
if (!presignRes.ok) {
|
||||||
|
const body = await presignRes.json().catch(() => ({})) as { message?: string };
|
||||||
|
avatarError = body.message ?? `Failed to prepare upload (${presignRes.status})`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { upload_url, key } = await presignRes.json() as { upload_url: string; key: string };
|
||||||
|
|
||||||
|
// Step 2: PUT blob directly to MinIO
|
||||||
|
const putRes = await fetch(upload_url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': mimeType },
|
||||||
|
body: blob
|
||||||
|
});
|
||||||
|
if (!putRes.ok) {
|
||||||
|
avatarError = `Upload failed (${putRes.status})`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: record key in PocketBase and get fresh presigned GET URL
|
||||||
|
const patchRes = await fetch('/api/profile/avatar', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key })
|
||||||
|
});
|
||||||
|
if (!patchRes.ok) {
|
||||||
|
const body = await patchRes.json().catch(() => ({})) as { message?: string };
|
||||||
|
avatarError = body.message ?? `Failed to save avatar (${patchRes.status})`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await patchRes.json() as { avatar_url: string | null };
|
||||||
|
avatarUrl = result.avatar_url;
|
||||||
|
} catch {
|
||||||
|
avatarError = 'Network error during upload';
|
||||||
|
} finally {
|
||||||
|
avatarUploading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCropCancel() {
|
||||||
|
cropFile = null;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Settings ────────────────────────────────────────────────────────────────
|
// ── Settings ────────────────────────────────────────────────────────────────
|
||||||
let voices = $state<string[]>([]);
|
let voices = $state<string[]>([]);
|
||||||
let voicesLoaded = $state(false);
|
let voicesLoaded = $state(false);
|
||||||
@@ -141,13 +213,71 @@
|
|||||||
<title>Profile — libnovel</title>
|
<title>Profile — libnovel</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if cropFile && browser}
|
||||||
|
{#await import('$lib/components/AvatarCropModal.svelte') then { default: AvatarCropModal }}
|
||||||
|
<AvatarCropModal
|
||||||
|
file={cropFile}
|
||||||
|
onconfirm={handleCropConfirm}
|
||||||
|
oncancel={handleCropCancel}
|
||||||
|
/>
|
||||||
|
{/await}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Hidden logout form used when user ends their own session -->
|
<!-- Hidden logout form used when user ends their own session -->
|
||||||
<form id="logout-form" method="POST" action="/logout" class="hidden"></form>
|
<form id="logout-form" method="POST" action="/logout" class="hidden"></form>
|
||||||
|
|
||||||
<div class="max-w-xl mx-auto space-y-10">
|
<div class="max-w-xl mx-auto space-y-10">
|
||||||
<div>
|
<div class="flex items-center gap-5">
|
||||||
<h1 class="text-2xl font-bold text-zinc-100">Profile</h1>
|
<!-- Avatar -->
|
||||||
<p class="text-zinc-400 text-sm mt-1">Signed in as <span class="text-zinc-200 font-medium">{data.user.username}</span></p>
|
<div class="relative shrink-0">
|
||||||
|
<button
|
||||||
|
onclick={() => fileInput?.click()}
|
||||||
|
class="group relative w-20 h-20 rounded-full overflow-hidden ring-2 ring-zinc-600 hover:ring-amber-400 transition-all focus:outline-none focus:ring-amber-400"
|
||||||
|
title="Change profile picture"
|
||||||
|
disabled={avatarUploading}
|
||||||
|
>
|
||||||
|
{#if avatarUrl}
|
||||||
|
<img src={avatarUrl} alt="Profile" class="w-full h-full object-cover" />
|
||||||
|
{:else}
|
||||||
|
<div class="w-full h-full bg-zinc-700 flex items-center justify-center">
|
||||||
|
<svg class="w-10 h-10 text-zinc-400" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<!-- Hover overlay -->
|
||||||
|
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{#if avatarUploading}
|
||||||
|
<svg class="w-5 h-5 text-white animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
class="hidden"
|
||||||
|
onchange={handleAvatarChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-zinc-100">{data.user.username}</h1>
|
||||||
|
<p class="text-zinc-400 text-sm mt-0.5 capitalize">{data.user.role}</p>
|
||||||
|
{#if avatarError}
|
||||||
|
<p class="text-red-400 text-xs mt-1">{avatarError}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-zinc-500 text-xs mt-1">Click avatar to change photo</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Reading settings ─────────────────────────────────────────────────── -->
|
<!-- ── Reading settings ─────────────────────────────────────────────────── -->
|
||||||
|
|||||||
@@ -3,5 +3,11 @@ import tailwindcss from '@tailwindcss/vite';
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss(), sveltekit()]
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
|
ssr: {
|
||||||
|
// Force these packages to be bundled into the server output rather than
|
||||||
|
// treated as external requires. The production Docker image has no
|
||||||
|
// node_modules, so anything used in server-side code must be inlined.
|
||||||
|
noExternal: ['marked']
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user