Compare commits
97 Commits
db06e3ecdc
...
v1.0.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b11f4ab6b4 | ||
|
|
3e4b1c0484 | ||
|
|
b5bc6ff3de | ||
|
|
8d4bba7964 | ||
|
|
2e5fe54615 | ||
|
|
81265510ef | ||
|
|
4d3c093612 | ||
|
|
937ba052fc | ||
|
|
479d201da9 | ||
|
|
1242cc7eb3 | ||
|
|
0b6dbeb042 | ||
|
|
c06877069f | ||
|
|
261c738fc0 | ||
|
|
5528abe4b0 | ||
|
|
09cdda2a07 | ||
|
|
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:
|
||||
push:
|
||||
branches: ["main", "master"]
|
||||
branches: ["main", "master", "v2"]
|
||||
paths:
|
||||
- "scraper/**"
|
||||
- "justfile"
|
||||
- ".gitea/workflows/ci-scraper.yaml"
|
||||
pull_request:
|
||||
branches: ["main", "master"]
|
||||
branches: ["main", "master", "v2"]
|
||||
paths:
|
||||
- "scraper/**"
|
||||
- "justfile"
|
||||
- ".gitea/workflows/ci-scraper.yaml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── lint & vet ───────────────────────────────────────────────────────────────
|
||||
lint:
|
||||
@@ -27,11 +29,11 @@ jobs:
|
||||
go-version-file: scraper/go.mod
|
||||
cache-dependency-path: scraper/go.sum
|
||||
|
||||
- name: Install just
|
||||
uses: extractions/setup-just@v2
|
||||
|
||||
- name: go vet
|
||||
run: just lint
|
||||
working-directory: scraper
|
||||
run: |
|
||||
go vet ./...
|
||||
go vet -tags integration ./...
|
||||
|
||||
# ── tests ────────────────────────────────────────────────────────────────────
|
||||
test:
|
||||
@@ -45,36 +47,33 @@ jobs:
|
||||
go-version-file: scraper/go.mod
|
||||
cache-dependency-path: scraper/go.sum
|
||||
|
||||
- name: Install just
|
||||
uses: extractions/setup-just@v2
|
||||
|
||||
- name: Run tests
|
||||
run: just test
|
||||
working-directory: scraper
|
||||
run: go test -short -race -count=1 -timeout=60s ./...
|
||||
|
||||
# ── build binary ─────────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build
|
||||
# ── push to Docker Hub ───────────────────────────────────────────────────────
|
||||
docker:
|
||||
name: Docker Push
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test]
|
||||
if: gitea.event_name == 'push'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
go-version-file: scraper/go.mod
|
||||
cache-dependency-path: scraper/go.sum
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Install just
|
||||
uses: extractions/setup-just@v2
|
||||
|
||||
- name: Build binary
|
||||
run: |
|
||||
cd scraper && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -ldflags="-s -w" -o bin/scraper ./cmd/scraper
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
name: scraper-linux-amd64
|
||||
path: scraper/bin/scraper
|
||||
retention-days: 7
|
||||
context: scraper
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKER_USER }}/libnovel-scraper:latest
|
||||
${{ secrets.DOCKER_USER }}/libnovel-scraper:${{ gitea.sha }}
|
||||
build-args: |
|
||||
VERSION=${{ gitea.sha }}
|
||||
COMMIT=${{ gitea.sha }}
|
||||
|
||||
@@ -2,23 +2,28 @@ name: CI / UI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "master"]
|
||||
branches: ["main", "master", "v2"]
|
||||
paths:
|
||||
- "ui/**"
|
||||
- "justfile"
|
||||
- ".gitea/workflows/ci-ui.yaml"
|
||||
pull_request:
|
||||
branches: ["main", "master"]
|
||||
branches: ["main", "master", "v2"]
|
||||
paths:
|
||||
- "ui/**"
|
||||
- "justfile"
|
||||
- ".gitea/workflows/ci-ui.yaml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── type-check & build ───────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ui
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -28,14 +33,38 @@ jobs:
|
||||
cache: npm
|
||||
cache-dependency-path: ui/package-lock.json
|
||||
|
||||
- name: Install just
|
||||
uses: extractions/setup-just@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: just ui-install
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
run: just ui-check
|
||||
run: npm run check
|
||||
|
||||
- name: Build
|
||||
run: just ui-build
|
||||
run: npm run build
|
||||
|
||||
# ── push to Docker Hub ───────────────────────────────────────────────────────
|
||||
docker:
|
||||
name: Docker Push
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: gitea.event_name == 'push'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ui
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKER_USER }}/libnovel-ui:latest
|
||||
${{ secrets.DOCKER_USER }}/libnovel-ui:${{ gitea.sha }}
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ gitea.sha }}
|
||||
BUILD_COMMIT=${{ gitea.sha }}
|
||||
|
||||
@@ -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/**"
|
||||
- "justfile"
|
||||
- ".gitea/workflows/ios.yaml"
|
||||
- ".gitea/workflows/ios-release.yaml"
|
||||
pull_request:
|
||||
branches: ["v2", "main"]
|
||||
paths:
|
||||
- "ios/**"
|
||||
- "justfile"
|
||||
- ".gitea/workflows/ios.yaml"
|
||||
- ".gitea/workflows/ios-release.yaml"
|
||||
|
||||
concurrency:
|
||||
group: ios-macos-runner
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── build ─────────────────────────────────────────────────────────────────
|
||||
# ── build (simulator) ─────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build
|
||||
runs-on: macos-latest
|
||||
@@ -24,15 +30,14 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install just
|
||||
run: brew install just
|
||||
|
||||
- name: Install xcodegen
|
||||
run: brew install xcodegen
|
||||
run: command -v just || brew install just
|
||||
|
||||
- name: Build (simulator)
|
||||
env:
|
||||
USER: runner
|
||||
run: just ios-build
|
||||
|
||||
# ── test ──────────────────────────────────────────────────────────────────
|
||||
# ── unit tests ────────────────────────────────────────────────────────────
|
||||
test:
|
||||
name: Test
|
||||
runs-on: macos-latest
|
||||
@@ -42,12 +47,11 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install just
|
||||
run: brew install just
|
||||
|
||||
- name: Install xcodegen
|
||||
run: brew install xcodegen
|
||||
run: command -v just || brew install just
|
||||
|
||||
- name: Run unit tests
|
||||
env:
|
||||
USER: runner
|
||||
run: just ios-test
|
||||
|
||||
- name: Upload test results
|
||||
@@ -57,53 +61,3 @@ jobs:
|
||||
name: test-results
|
||||
path: ios/LibNovel/test-results.xml
|
||||
retention-days: 7
|
||||
|
||||
# ── archive (release IPA) ─────────────────────────────────────────────────
|
||||
# Runs only on pushes to v2/main (not PRs).
|
||||
# Requires secrets: APPLE_CERTIFICATE_BASE64, APPLE_CERTIFICATE_PASSWORD,
|
||||
# APPLE_PROVISIONING_PROFILE_BASE64, KEYCHAIN_PASSWORD
|
||||
# Also requires ios/LibNovel/ExportOptions.plist to exist.
|
||||
#
|
||||
# archive:
|
||||
# name: Archive
|
||||
# runs-on: macos-latest
|
||||
# needs: [build, test]
|
||||
# if: gitea.event_name == 'push'
|
||||
#
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
#
|
||||
# - name: Install just
|
||||
# run: brew install just
|
||||
#
|
||||
# - name: Install xcodegen
|
||||
# run: brew install xcodegen
|
||||
#
|
||||
# - name: Import signing certificate
|
||||
# env:
|
||||
# CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
||||
# CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
# KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
# run: |
|
||||
# KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
# security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
# security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
# security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
# echo "$CERTIFICATE_BASE64" | base64 --decode > $RUNNER_TEMP/cert.p12
|
||||
# security import $RUNNER_TEMP/cert.p12 \
|
||||
# -P "$CERTIFICATE_PASSWORD" \
|
||||
# -A -t cert -f pkcs12 \
|
||||
# -k $KEYCHAIN_PATH
|
||||
# security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
#
|
||||
# - name: Import provisioning profile
|
||||
# env:
|
||||
# PROFILE_BASE64: ${{ secrets.APPLE_PROVISIONING_PROFILE_BASE64 }}
|
||||
# run: |
|
||||
# PP_PATH=$RUNNER_TEMP/profile.mobileprovision
|
||||
# echo "$PROFILE_BASE64" | base64 --decode > $PP_PATH
|
||||
# mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
# cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/
|
||||
#
|
||||
# - name: Archive & export IPA
|
||||
# run: just ios-archive && just ios-export
|
||||
|
||||
68
.gitea/workflows/release-scraper.yaml
Normal file
68
.gitea/workflows/release-scraper.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
name: Release / Scraper
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
concurrency:
|
||||
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── lint & test ──────────────────────────────────────────────────────────────
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: scraper/go.mod
|
||||
cache-dependency-path: scraper/go.sum
|
||||
|
||||
- name: go vet
|
||||
working-directory: scraper
|
||||
run: |
|
||||
go vet ./...
|
||||
go vet -tags integration ./...
|
||||
|
||||
- name: Run tests
|
||||
working-directory: scraper
|
||||
run: go test -short -race -count=1 -timeout=60s ./...
|
||||
|
||||
# ── docker build & push ──────────────────────────────────────────────────────
|
||||
docker:
|
||||
name: Docker
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_USER }}/libnovel-scraper
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: scraper
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
COMMIT=${{ gitea.sha }}
|
||||
@@ -5,15 +5,18 @@ on:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ui
|
||||
concurrency:
|
||||
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── type-check & build ───────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ui
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -37,33 +40,32 @@ jobs:
|
||||
name: Docker
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: .
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: gitea.kalekber.cc
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: gitea.kalekber.cc/kamil/libnovel-ui
|
||||
images: ${{ secrets.DOCKER_USER }}/libnovel-ui
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./ui
|
||||
context: ui
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.meta.outputs.version }}
|
||||
BUILD_COMMIT=${{ gitea.sha }}
|
||||
|
||||
@@ -35,6 +35,7 @@ services:
|
||||
mc mb --ignore-existing local/libnovel-chapters;
|
||||
mc mb --ignore-existing local/libnovel-audio;
|
||||
mc mb --ignore-existing local/libnovel-browse;
|
||||
mc mb --ignore-existing local/libnovel-avatars;
|
||||
echo 'buckets ready';
|
||||
"
|
||||
environment:
|
||||
@@ -81,6 +82,9 @@ services:
|
||||
build:
|
||||
context: ./scraper
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VERSION: "${GIT_TAG:-dev}"
|
||||
COMMIT: "${GIT_COMMIT:-unknown}"
|
||||
#container_name: libnovel-scraper
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@@ -106,6 +110,7 @@ services:
|
||||
MINIO_BUCKET_CHAPTERS: "${MINIO_BUCKET_CHAPTERS:-libnovel-chapters}"
|
||||
MINIO_BUCKET_AUDIO: "${MINIO_BUCKET_AUDIO:-libnovel-audio}"
|
||||
MINIO_BUCKET_BROWSE: "${MINIO_BUCKET_BROWSE:-libnovel-browse}"
|
||||
MINIO_BUCKET_AVATARS: "${MINIO_BUCKET_AVATARS:-libnovel-avatars}"
|
||||
# Public endpoint used to sign presigned audio URLs so browsers can reach them.
|
||||
# Leave empty to use MINIO_ENDPOINT (fine for local dev).
|
||||
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-}"
|
||||
@@ -129,6 +134,9 @@ services:
|
||||
build:
|
||||
context: ./ui
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
BUILD_VERSION: "${GIT_TAG:-dev}"
|
||||
BUILD_COMMIT: "${GIT_COMMIT:-unknown}"
|
||||
# container_name: libnovel-ui
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@@ -147,7 +155,7 @@ services:
|
||||
ports:
|
||||
- "${UI_PORT:-5252}:3000"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/"]
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
10
ios/LibNovel/.gitignore
vendored
Normal file
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 */; };
|
||||
3521DFD5FCBBED7B90368829 /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC338B05EA6DB22900712000 /* LibraryViewModel.swift */; };
|
||||
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5C115992F1CE2326236765 /* RootTabView.swift */; };
|
||||
41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC6F837FF2E902E334ED72E /* String+App.swift */; };
|
||||
4BB2C76262D5BD5DAD0D5D28 /* LibNovelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C918833E173D6B44D06955 /* LibNovelTests.swift */; };
|
||||
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */; };
|
||||
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F219788AE5ACBD6F240674F5 /* AuthStore.swift */; };
|
||||
@@ -23,14 +24,18 @@
|
||||
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */; };
|
||||
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.swift */; };
|
||||
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */; };
|
||||
A1C3F2B84D9E72A1BC054F17 /* CommentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */; };
|
||||
A2F1C3B84E9D71A0BC164F28 /* AccountMenuSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */; };
|
||||
A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB13E89E50529E3081533A66 /* AudioPlayerService.swift */; };
|
||||
BD2CA5EE70D102CA3B153485 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 6313AB4B3A5464F647791174 /* MarkdownUI */; };
|
||||
AA11BB22CC33DD44EE55FF66 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF67 /* UserProfileView.swift */; };
|
||||
BB22CC33DD44EE55FF66AA11 /* UserProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB22CC33DD44EE55FF66AA12 /* UserProfileViewModel.swift */; };
|
||||
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */; };
|
||||
C3D7A2E15F8B04C9AB163D50 /* AvatarCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */; };
|
||||
C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB2E843D93461074A89A171 /* HomeViewModel.swift */; };
|
||||
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 09584EAB68A07B47F876A062 /* Kingfisher */; };
|
||||
D5E2A1C96F3B08D0F74C6B50 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */; };
|
||||
E1F564399D1325F6A1B2B84F /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C21107BECA55C07416E0CB8B /* LibraryView.swift */; };
|
||||
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D83BB88C4306BE7A4F947CB /* Color+App.swift */; };
|
||||
A1B2C3D4E5F6789012345678 /* String+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F67890123456789A /* String+App.swift */; };
|
||||
EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6268D60803940CBD38FB921 /* HomeView.swift */; };
|
||||
F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E89FD8F46747CA653C5203D /* CommonViews.swift */; };
|
||||
F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CAFB96D2500F34F0B0C860C /* NavDestination.swift */; };
|
||||
@@ -51,7 +56,7 @@
|
||||
/* Begin PBXFileReference section */
|
||||
1B8BF3DB582A658386E402C7 /* LibNovel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LibNovel.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = "<group>"; };
|
||||
235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LibNovelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LibNovelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
2D5C115992F1CE2326236765 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = "<group>"; };
|
||||
39DE056C37FBC5EED8771821 /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = "<group>"; };
|
||||
3AB2E843D93461074A89A171 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
||||
@@ -68,16 +73,22 @@
|
||||
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = "<group>"; };
|
||||
9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewModel.swift; sourceTree = "<group>"; };
|
||||
9D83BB88C4306BE7A4F947CB /* Color+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+App.swift"; sourceTree = "<group>"; };
|
||||
B2C3D4E5F67890123456789A /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+App.swift"; sourceTree = "<group>"; };
|
||||
AA11BB22CC33DD44EE55FF67 /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = "<group>"; };
|
||||
B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsView.swift; sourceTree = "<group>"; };
|
||||
B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMenuSheet.swift; sourceTree = "<group>"; };
|
||||
B4C918833E173D6B44D06955 /* LibNovelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelTests.swift; sourceTree = "<group>"; };
|
||||
B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
||||
BB22CC33DD44EE55FF66AA12 /* UserProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileViewModel.swift; sourceTree = "<group>"; };
|
||||
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
|
||||
C21107BECA55C07416E0CB8B /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
|
||||
C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
|
||||
D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCropView.swift; sourceTree = "<group>"; };
|
||||
D6268D60803940CBD38FB921 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
|
||||
DB13E89E50529E3081533A66 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; };
|
||||
DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViews.swift; sourceTree = "<group>"; };
|
||||
F219788AE5ACBD6F240674F5 /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = "<group>"; };
|
||||
FC338B05EA6DB22900712000 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = "<group>"; };
|
||||
FEC6F837FF2E902E334ED72E /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+App.swift"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -86,7 +97,6 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */,
|
||||
BD2CA5EE70D102CA3B153485 /* MarkdownUI in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -113,6 +123,7 @@
|
||||
FA994FD601E79EC811D822A4 /* Library */,
|
||||
89F2CB14192E7D7565A588E0 /* Player */,
|
||||
3DB66C5703A4CCAFFA1B7AFE /* Profile */,
|
||||
E6A2B4C07F1D38E0A95B3C72 /* Search */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
@@ -129,6 +140,9 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */,
|
||||
D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */,
|
||||
B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */,
|
||||
AA11BB22CC33DD44EE55FF67 /* UserProfileView.swift */,
|
||||
);
|
||||
path = Profile;
|
||||
sourceTree = "<group>";
|
||||
@@ -242,6 +256,7 @@
|
||||
3AB2E843D93461074A89A171 /* HomeViewModel.swift */,
|
||||
FC338B05EA6DB22900712000 /* LibraryViewModel.swift */,
|
||||
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */,
|
||||
BB22CC33DD44EE55FF66AA12 /* UserProfileViewModel.swift */,
|
||||
);
|
||||
path = ViewModels;
|
||||
sourceTree = "<group>";
|
||||
@@ -255,6 +270,14 @@
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E6A2B4C07F1D38E0A95B3C72 /* Search */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */,
|
||||
);
|
||||
path = Search;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FA994FD601E79EC811D822A4 /* Library */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -267,6 +290,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
39DE056C37FBC5EED8771821 /* BookDetailView.swift */,
|
||||
B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */,
|
||||
);
|
||||
path = BookDetail;
|
||||
sourceTree = "<group>";
|
||||
@@ -276,7 +300,7 @@
|
||||
children = (
|
||||
9D83BB88C4306BE7A4F947CB /* Color+App.swift */,
|
||||
7CAFB96D2500F34F0B0C860C /* NavDestination.swift */,
|
||||
B2C3D4E5F67890123456789A /* String+App.swift */,
|
||||
FEC6F837FF2E902E334ED72E /* String+App.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
@@ -327,7 +351,6 @@
|
||||
name = LibNovel;
|
||||
packageProductDependencies = (
|
||||
09584EAB68A07B47F876A062 /* Kingfisher */,
|
||||
6313AB4B3A5464F647791174 /* MarkdownUI */,
|
||||
);
|
||||
productName = LibNovel;
|
||||
productReference = 1B8BF3DB582A658386E402C7 /* LibNovel.app */;
|
||||
@@ -340,7 +363,7 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1600;
|
||||
LastUpgradeCheck = 2630;
|
||||
};
|
||||
buildConfigurationList = D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */;
|
||||
developmentRegion = en;
|
||||
@@ -353,7 +376,6 @@
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
||||
C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 6318D3C6F0DC6C8E2C377103 /* Products */;
|
||||
@@ -395,13 +417,13 @@
|
||||
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */,
|
||||
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */,
|
||||
749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */,
|
||||
A1C3F2B84D9E72A1BC054F17 /* CommentsView.swift in Sources */,
|
||||
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */,
|
||||
08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */,
|
||||
2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */,
|
||||
FEFB5FDC2424D22914458001 /* ChapterReaderView.swift in Sources */,
|
||||
2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */,
|
||||
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */,
|
||||
A1B2C3D4E5F6789012345678 /* String+App.swift in Sources */,
|
||||
F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */,
|
||||
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */,
|
||||
EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */,
|
||||
@@ -413,8 +435,14 @@
|
||||
F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */,
|
||||
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */,
|
||||
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */,
|
||||
C3D7A2E15F8B04C9AB163D50 /* AvatarCropView.swift in Sources */,
|
||||
A2F1C3B84E9D71A0BC164F28 /* AccountMenuSheet.swift in Sources */,
|
||||
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */,
|
||||
AA11BB22CC33DD44EE55FF66 /* UserProfileView.swift in Sources */,
|
||||
BB22CC33DD44EE55FF66AA11 /* UserProfileViewModel.swift in Sources */,
|
||||
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */,
|
||||
D5E2A1C96F3B08D0F74C6B50 /* SearchView.swift in Sources */,
|
||||
41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -439,7 +467,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel.tests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel.tests;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LibNovel.app/LibNovel";
|
||||
@@ -456,7 +484,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel.tests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel.tests;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LibNovel.app/LibNovel";
|
||||
@@ -467,16 +495,21 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = GHZXC6FVMU;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
@@ -516,11 +549,12 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 1000;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
@@ -543,6 +577,7 @@
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.10;
|
||||
@@ -553,16 +588,25 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
DEVELOPMENT_TEAM = GHZXC6FVMU;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = GHZXC6FVMU;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel;
|
||||
PROVISIONING_PROFILE = "af592c3a-f60b-4ac1-a14f-30b8a206017f";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LibNovel Distribution";
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
@@ -602,11 +646,12 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 1000;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
@@ -622,6 +667,7 @@
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
@@ -670,14 +716,6 @@
|
||||
minimumVersion = 8.0.0;
|
||||
};
|
||||
};
|
||||
C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.4.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
@@ -686,11 +724,6 @@
|
||||
package = AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
||||
productName = Kingfisher;
|
||||
};
|
||||
6313AB4B3A5464F647791174 /* MarkdownUI */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
|
||||
productName = MarkdownUI;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = A10A669C0C8B43078C0FEE9F /* Project object */;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "18350c2bfa3935125b6f4e9817e7ed4508588c07142d420b8b8ee00640a57853",
|
||||
"originHash" : "ad75ae2d3b8d8b80d99635f65213a3c1092464aa54a86354f850b8317b6fa240",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "kingfisher",
|
||||
@@ -9,33 +9,6 @@
|
||||
"revision" : "c92b84898e34ab46ff0dad86c02a0acbe2d87008",
|
||||
"version" : "8.8.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "networkimage",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/NetworkImage",
|
||||
"state" : {
|
||||
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
|
||||
"version" : "6.0.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-cmark",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swiftlang/swift-cmark",
|
||||
"state" : {
|
||||
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
|
||||
"version" : "0.7.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-markdown-ui",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
|
||||
"state" : {
|
||||
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
|
||||
"version" : "2.4.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1600"
|
||||
version = "1.7">
|
||||
LastUpgradeVersion = "2630"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
runPostActionsOnFailure = "NO">
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
@@ -27,8 +26,7 @@
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
@@ -51,8 +49,6 @@
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
@@ -74,12 +70,10 @@
|
||||
ReferencedContainer = "container:LibNovel.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "LIBNOVEL_BASE_URL"
|
||||
value = "["isEnabled": true, "value": "https://v2.libnovel.kalekber.cc"]"
|
||||
value = "["value": "https://v2.libnovel.kalekber.cc", "isEnabled": true]"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
@@ -100,8 +94,6 @@
|
||||
ReferencedContainer = "container:LibNovel.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
|
||||
@@ -8,51 +8,52 @@ struct RootTabView: View {
|
||||
|
||||
@State private var selectedTab: Tab = .home
|
||||
@State private var showFullPlayer: Bool = false
|
||||
@State private var showCompactControls: Bool = false
|
||||
|
||||
/// Live drag offset while the user is dragging the full player down.
|
||||
@State private var fullPlayerDragOffset: CGFloat = 0
|
||||
|
||||
enum Tab: Hashable {
|
||||
case home, library, browse, profile
|
||||
case home, library, browse, search
|
||||
}
|
||||
|
||||
/// Height of the mini player bar (progress line 2pt + vertical padding 20pt + content ~44pt)
|
||||
private let miniPlayerBarHeight: CGFloat = AppLayout.miniPlayerBarHeight
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
TabView(selection: $selectedTab) {
|
||||
HomeView()
|
||||
.tabItem { Label("Home", systemImage: "house") }
|
||||
.tabItem { Label("Home", systemImage: "house.fill") }
|
||||
.tag(Tab.home)
|
||||
|
||||
LibraryView()
|
||||
.tabItem { Label("Library", systemImage: "books.vertical") }
|
||||
.tabItem { Label("Library", systemImage: "book.pages.fill") }
|
||||
.tag(Tab.library)
|
||||
|
||||
BrowseView()
|
||||
.tabItem { Label("Discover", systemImage: "sparkles") }
|
||||
.tag(Tab.browse)
|
||||
|
||||
ProfileView()
|
||||
.tabItem { Label("Profile", systemImage: "person.crop.circle") }
|
||||
.tag(Tab.profile)
|
||||
}
|
||||
// Reserve space for the mini-player above the tab bar so scroll content
|
||||
// never slides beneath it.
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if audioPlayer.isActive {
|
||||
Color.clear.frame(height: miniPlayerBarHeight)
|
||||
}
|
||||
SearchView()
|
||||
.tabItem { Label("Search", systemImage: "magnifyingglass") }
|
||||
.tag(Tab.search)
|
||||
}
|
||||
|
||||
// Mini-player pinned above the tab bar (hidden while full player is open)
|
||||
// Floating circular player button (hidden while full player is open)
|
||||
if audioPlayer.isActive && !showFullPlayer {
|
||||
MiniPlayerView(showFullPlayer: $showFullPlayer)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, max(tabBarHeight - 2, 0))
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive)
|
||||
ZStack {
|
||||
// Compact controls overlay (bottom sheet)
|
||||
if showCompactControls {
|
||||
CompactPlayerControls(isPresented: $showCompactControls)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
|
||||
// Floating button (always on top)
|
||||
FloatingPlayerButton(
|
||||
showFullPlayer: $showFullPlayer,
|
||||
showControls: $showCompactControls
|
||||
)
|
||||
}
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive)
|
||||
}
|
||||
|
||||
// Full player — slides up from the bottom as a custom overlay (not a sheet)
|
||||
@@ -93,14 +94,6 @@ struct RootTabView: View {
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer)
|
||||
}
|
||||
|
||||
// Approximate safe-area-aware tab bar height
|
||||
private var tabBarHeight: CGFloat {
|
||||
let window = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.first?.windows.first(where: \.isKeyWindow)
|
||||
let bottomInset = window?.safeAreaInsets.bottom ?? 0
|
||||
return 49 + bottomInset // 49pt is the standard iOS tab bar height
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: showCompactControls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import SwiftUI
|
||||
// MARK: - App accent color (amber — mirrors Tailwind amber-500 #f59e0b)
|
||||
extension Color {
|
||||
static let amber = Color(red: 0.96, green: 0.62, blue: 0.04)
|
||||
static let amberLight = Color(red: 1.0, green: 0.84, blue: 0.40)
|
||||
}
|
||||
|
||||
extension ShapeStyle where Self == Color {
|
||||
|
||||
@@ -5,4 +5,100 @@ import SwiftUI
|
||||
enum NavDestination: Hashable {
|
||||
case book(String) // slug
|
||||
case chapter(String, Int) // slug + chapter number
|
||||
case userProfile(String) // username
|
||||
}
|
||||
|
||||
// MARK: - View extensions for shared navigation + error alert patterns
|
||||
|
||||
extension View {
|
||||
/// Registers the app-wide navigation destinations for NavDestination values.
|
||||
/// Apply once per NavigationStack instead of repeating the switch in every tab.
|
||||
func appNavigationDestination() -> some View {
|
||||
modifier(AppNavigationDestinationModifier())
|
||||
}
|
||||
|
||||
/// Presents a standard "Error" alert driven by an optional String binding.
|
||||
/// Dismissing the alert sets the binding back to nil.
|
||||
func errorAlert(_ error: Binding<String?>) -> some View {
|
||||
alert("Error", isPresented: Binding(
|
||||
get: { error.wrappedValue != nil },
|
||||
set: { if !$0 { error.wrappedValue = nil } }
|
||||
)) {
|
||||
Button("OK") { error.wrappedValue = nil }
|
||||
} message: {
|
||||
Text(error.wrappedValue ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation destination modifier
|
||||
|
||||
private struct AppNavigationDestinationModifier: ViewModifier {
|
||||
@Namespace private var zoomNamespace
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 18.0, *) {
|
||||
content
|
||||
.navigationDestination(for: NavDestination.self) { dest in
|
||||
switch dest {
|
||||
case .book(let slug):
|
||||
BookDetailView(slug: slug)
|
||||
.navigationTransition(.zoom(sourceID: slug, in: zoomNamespace))
|
||||
case .chapter(let slug, let n):
|
||||
ChapterReaderView(slug: slug, chapterNumber: n)
|
||||
case .userProfile(let username):
|
||||
UserProfileView(username: username)
|
||||
}
|
||||
}
|
||||
// Expose namespace to child views via environment
|
||||
.environment(\.bookZoomNamespace, zoomNamespace)
|
||||
} else {
|
||||
content
|
||||
.navigationDestination(for: NavDestination.self) { dest in
|
||||
switch dest {
|
||||
case .book(let slug): BookDetailView(slug: slug)
|
||||
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
||||
case .userProfile(let username): UserProfileView(username: username)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Environment key for zoom namespace
|
||||
|
||||
struct BookZoomNamespaceKey: EnvironmentKey {
|
||||
static var defaultValue: Namespace.ID? { nil }
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var bookZoomNamespace: Namespace.ID? {
|
||||
get { self[BookZoomNamespaceKey.self] }
|
||||
set { self[BookZoomNamespaceKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cover card zoom source modifier
|
||||
|
||||
/// Apply this to any cover image that should be a zoom source for book navigation.
|
||||
/// Falls back to a no-op on iOS 17 or when no namespace is available.
|
||||
struct BookCoverZoomSource: ViewModifier {
|
||||
let slug: String
|
||||
@Environment(\.bookZoomNamespace) private var namespace
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 18.0, *), let ns = namespace {
|
||||
content.matchedTransitionSource(id: slug, in: ns)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Marks a cover image as the zoom source for a book's navigation transition.
|
||||
func bookCoverZoomSource(slug: String) -> some View {
|
||||
modifier(BookCoverZoomSource(slug: slug))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,18 +3,28 @@ import Foundation
|
||||
// MARK: - String helpers for display purposes
|
||||
|
||||
extension String {
|
||||
/// Strips trailing relative-date suffixes (e.g. "2 years ago", "3 days ago")
|
||||
/// that may be embedded in chapter titles coming from older scraped records.
|
||||
/// Strips trailing relative-date suffixes (e.g. "2 years ago", "3 days ago",
|
||||
/// or "(One)4 years ago" where the number is attached without a preceding space).
|
||||
func strippingTrailingDate() -> String {
|
||||
let units = ["second", "minute", "hour", "day", "week", "month", "year"]
|
||||
let lower = self.lowercased()
|
||||
for unit in units {
|
||||
for suffix in [unit + "s ago", unit + " ago"] {
|
||||
guard let suffixRange = lower.range(of: suffix, options: .backwards) else { continue }
|
||||
// Walk backwards past whitespace to find the numeric token
|
||||
// Everything before the suffix
|
||||
let before = String(self[self.startIndex ..< suffixRange.lowerBound])
|
||||
let trimmed = before.trimmingCharacters(in: .whitespaces)
|
||||
// Find start of last word (should be a number)
|
||||
// Strip trailing digits (the numeric count, which may be attached without a space)
|
||||
var result = trimmed
|
||||
while let last = result.last, last.isNumber {
|
||||
result.removeLast()
|
||||
}
|
||||
result = result.trimmingCharacters(in: .whitespaces)
|
||||
if result != trimmed {
|
||||
// We actually stripped some digits — return cleaned result
|
||||
return result
|
||||
}
|
||||
// Fallback: number preceded by space
|
||||
if let spaceIdx = trimmed.lastIndex(of: " ") {
|
||||
let potentialNum = String(trimmed[trimmed.index(after: spaceIdx)...])
|
||||
if Int(potentialNum) != nil {
|
||||
@@ -22,7 +32,6 @@ extension String {
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
} else if Int(trimmed) != nil {
|
||||
// The entire string is just a number + "ago"
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -30,11 +39,3 @@ extension String {
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App-wide layout constants
|
||||
|
||||
enum AppLayout {
|
||||
/// Height of the persistent mini-player bar:
|
||||
/// 2pt progress line + 20pt vertical padding + ~44pt content row.
|
||||
static let miniPlayerBarHeight: CGFloat = 56
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Book
|
||||
|
||||
@@ -72,27 +73,6 @@ struct ChapterIndexBrief: Codable, Hashable {
|
||||
let title: String
|
||||
}
|
||||
|
||||
// MARK: - Progress
|
||||
|
||||
struct ReadingProgress: Codable {
|
||||
var id: String?
|
||||
let sessionId: String
|
||||
var userId: String?
|
||||
let slug: String
|
||||
var chapter: Int
|
||||
var audioTime: Double?
|
||||
let updated: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case sessionId = "session_id"
|
||||
case userId = "user_id"
|
||||
case slug, chapter
|
||||
case audioTime = "audio_time"
|
||||
case updated
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User Settings
|
||||
|
||||
struct UserSettings: Codable {
|
||||
@@ -107,6 +87,81 @@ struct UserSettings: Codable {
|
||||
static let `default` = UserSettings(id: nil, autoNext: false, voice: "af_bella", speed: 1.0)
|
||||
}
|
||||
|
||||
// MARK: - Reading Display Settings (local only — stored in UserDefaults)
|
||||
|
||||
enum ReaderTheme: String, CaseIterable, Codable {
|
||||
case white, sepia, night
|
||||
|
||||
var backgroundColor: Color {
|
||||
switch self {
|
||||
case .white: return Color(.sRGB, white: 1.0, opacity: 1)
|
||||
case .sepia: return Color(red: 0.97, green: 0.93, blue: 0.82)
|
||||
case .night: return Color(red: 0.10, green: 0.10, blue: 0.12)
|
||||
}
|
||||
}
|
||||
|
||||
var textColor: Color {
|
||||
switch self {
|
||||
case .white: return Color(.sRGB, white: 0.1, opacity: 1)
|
||||
case .sepia: return Color(red: 0.25, green: 0.18, blue: 0.08)
|
||||
case .night: return Color(red: 0.85, green: 0.85, blue: 0.87)
|
||||
}
|
||||
}
|
||||
|
||||
var colorScheme: ColorScheme? {
|
||||
switch self {
|
||||
case .white: return nil // follows system
|
||||
case .sepia: return .light
|
||||
case .night: return .dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ReaderFont: String, CaseIterable, Codable {
|
||||
case system = "System"
|
||||
case georgia = "Georgia"
|
||||
case newYork = "New York"
|
||||
|
||||
var fontName: String? {
|
||||
switch self {
|
||||
case .system: return nil
|
||||
case .georgia: return "Georgia"
|
||||
case .newYork: return "NewYorkMedium-Regular"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ReaderSettings: Codable, Equatable {
|
||||
var fontSize: CGFloat
|
||||
var lineSpacing: CGFloat
|
||||
var font: ReaderFont
|
||||
var theme: ReaderTheme
|
||||
var scrollMode: Bool
|
||||
|
||||
static let `default` = ReaderSettings(
|
||||
fontSize: 17,
|
||||
lineSpacing: 1.7,
|
||||
font: .system,
|
||||
theme: .white,
|
||||
scrollMode: false
|
||||
)
|
||||
|
||||
static let userDefaultsKey = "readerSettings"
|
||||
|
||||
static func load() -> ReaderSettings {
|
||||
guard let data = UserDefaults.standard.data(forKey: userDefaultsKey),
|
||||
let decoded = try? JSONDecoder().decode(ReaderSettings.self, from: data)
|
||||
else { return .default }
|
||||
return decoded
|
||||
}
|
||||
|
||||
func save() {
|
||||
if let data = try? JSONEncoder().encode(self) {
|
||||
UserDefaults.standard.set(data, forKey: ReaderSettings.userDefaultsKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User
|
||||
|
||||
struct AppUser: Codable, Identifiable {
|
||||
@@ -114,34 +169,33 @@ struct AppUser: Codable, Identifiable {
|
||||
let username: String
|
||||
let role: String
|
||||
let created: String
|
||||
let avatarURL: String?
|
||||
|
||||
var isAdmin: Bool { role == "admin" }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, username, role, created
|
||||
case avatarURL = "avatar_url"
|
||||
}
|
||||
|
||||
init(id: String, username: String, role: String, created: String, avatarURL: String?) {
|
||||
self.id = id
|
||||
self.username = username
|
||||
self.role = role
|
||||
self.created = created
|
||||
self.avatarURL = avatarURL
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(String.self, forKey: .id)
|
||||
username = try c.decode(String.self, forKey: .username)
|
||||
role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user"
|
||||
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
|
||||
id = try c.decode(String.self, forKey: .id)
|
||||
username = try c.decode(String.self, forKey: .username)
|
||||
role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user"
|
||||
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
|
||||
avatarURL = try c.decodeIfPresent(String.self, forKey: .avatarURL)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Browse / Novel Listing
|
||||
|
||||
struct NovelListing: Identifiable, Hashable {
|
||||
var id: String { slug.isEmpty ? url : slug }
|
||||
let slug: String
|
||||
let title: String
|
||||
let cover: String
|
||||
let rank: String
|
||||
let rating: String
|
||||
let chapters: String
|
||||
let url: String
|
||||
let author: String
|
||||
let status: String
|
||||
let genres: [String]
|
||||
}
|
||||
|
||||
// MARK: - Ranking
|
||||
|
||||
struct RankingItem: Codable, Identifiable {
|
||||
@@ -163,12 +217,6 @@ struct RankingItem: Codable, Identifiable {
|
||||
|
||||
// MARK: - Home
|
||||
|
||||
struct HomeData {
|
||||
let continueReading: [ContinueReadingItem]
|
||||
let recentlyUpdated: [Book]
|
||||
let stats: HomeStats
|
||||
}
|
||||
|
||||
struct ContinueReadingItem: Identifiable {
|
||||
var id: String { book.id }
|
||||
let book: Book
|
||||
@@ -201,17 +249,6 @@ struct UserSession: Codable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Book Detail
|
||||
|
||||
struct BookDetailData {
|
||||
let book: Book
|
||||
let chapters: [ChapterIndex]
|
||||
let previewChapters: [PreviewChapter]?
|
||||
let inLib: Bool
|
||||
let saved: Bool
|
||||
let lastChapter: Int?
|
||||
}
|
||||
|
||||
struct PreviewChapter: Codable, Identifiable {
|
||||
var id: Int { number }
|
||||
let number: Int
|
||||
@@ -219,34 +256,140 @@ struct PreviewChapter: Codable, Identifiable {
|
||||
let url: String
|
||||
}
|
||||
|
||||
// MARK: - Chapter Content
|
||||
|
||||
struct ChapterContent {
|
||||
let book: BookBrief
|
||||
let chapter: ChapterIndex
|
||||
let html: String
|
||||
let voices: [String]
|
||||
let prev: Int?
|
||||
let next: Int?
|
||||
let chapters: [ChapterIndexBrief]
|
||||
let isPreview: Bool
|
||||
}
|
||||
|
||||
struct BookBrief: Codable {
|
||||
let slug: String
|
||||
let title: String
|
||||
let cover: String
|
||||
}
|
||||
|
||||
// MARK: - Comments
|
||||
|
||||
struct BookComment: Identifiable, Codable, Hashable {
|
||||
let id: String
|
||||
let slug: String
|
||||
let userId: String
|
||||
let username: String
|
||||
let body: String
|
||||
var upvotes: Int
|
||||
var downvotes: Int
|
||||
let created: String
|
||||
let parentId: String // empty = top-level; non-empty = reply
|
||||
var replies: [BookComment]? // populated client-side from the API response
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, slug, username, body, upvotes, downvotes, created, replies
|
||||
case userId = "user_id"
|
||||
case parentId = "parent_id"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(String.self, forKey: .id)
|
||||
slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? ""
|
||||
userId = try c.decodeIfPresent(String.self, forKey: .userId) ?? ""
|
||||
username = try c.decodeIfPresent(String.self, forKey: .username) ?? ""
|
||||
body = try c.decodeIfPresent(String.self, forKey: .body) ?? ""
|
||||
upvotes = try c.decodeIfPresent(Int.self, forKey: .upvotes) ?? 0
|
||||
downvotes = try c.decodeIfPresent(Int.self, forKey: .downvotes) ?? 0
|
||||
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
|
||||
parentId = try c.decodeIfPresent(String.self, forKey: .parentId) ?? ""
|
||||
replies = try c.decodeIfPresent([BookComment].self, forKey: .replies)
|
||||
}
|
||||
}
|
||||
|
||||
struct CommentsResponse: Decodable {
|
||||
let comments: [BookComment]
|
||||
let myVotes: [String: String]
|
||||
let avatarUrls: [String: String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case comments
|
||||
case myVotes = "myVotes"
|
||||
case avatarUrls = "avatarUrls"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
comments = try c.decode([BookComment].self, forKey: .comments)
|
||||
myVotes = try c.decodeIfPresent([String: String].self, forKey: .myVotes) ?? [:]
|
||||
avatarUrls = try c.decodeIfPresent([String: String].self, forKey: .avatarUrls) ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User Profile (public)
|
||||
|
||||
struct PublicUserProfile: Decodable, Identifiable {
|
||||
let id: String
|
||||
let username: String
|
||||
let avatarUrl: String?
|
||||
let created: String
|
||||
let followerCount: Int
|
||||
let followingCount: Int
|
||||
let isSubscribed: Bool
|
||||
let isSelf: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, username, created
|
||||
case avatarUrl = "avatarUrl"
|
||||
case followerCount = "followerCount"
|
||||
case followingCount = "followingCount"
|
||||
case isSubscribed = "isSubscribed"
|
||||
case isSelf = "isSelf"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(String.self, forKey: .id)
|
||||
username = try c.decode(String.self, forKey: .username)
|
||||
avatarUrl = try c.decodeIfPresent(String.self, forKey: .avatarUrl)
|
||||
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
|
||||
followerCount = try c.decodeIfPresent(Int.self, forKey: .followerCount) ?? 0
|
||||
followingCount = try c.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0
|
||||
isSubscribed = try c.decodeIfPresent(Bool.self, forKey: .isSubscribed) ?? false
|
||||
isSelf = try c.decodeIfPresent(Bool.self, forKey: .isSelf) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subscription Feed
|
||||
|
||||
struct SubscriptionFeedItem: Identifiable, Decodable {
|
||||
var id: String { book.id + readerUsername }
|
||||
let book: Book
|
||||
let readerUsername: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case book
|
||||
case readerUsername = "readerUsername"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public User Library
|
||||
|
||||
struct PublicLibraryItem: Decodable, Identifiable {
|
||||
var id: String { book.id }
|
||||
let book: Book
|
||||
let lastChapter: Int?
|
||||
let saved: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case book
|
||||
case lastChapter = "last_chapter"
|
||||
case saved
|
||||
}
|
||||
}
|
||||
|
||||
struct PublicUserLibraryResponse: Decodable {
|
||||
let currentlyReading: [PublicLibraryItem]
|
||||
let library: [PublicLibraryItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case currentlyReading = "currently_reading"
|
||||
case library
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Audio
|
||||
|
||||
enum NextPrefetchStatus {
|
||||
case none, prefetching, prefetched, failed
|
||||
}
|
||||
|
||||
// MARK: - PocketBase list response
|
||||
|
||||
struct PBList<T: Codable>: Codable {
|
||||
let items: [T]
|
||||
let totalItems: Int
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ actor APIClient {
|
||||
|
||||
var baseURL: URL
|
||||
private var authCookie: String? // raw "libnovel_auth=<token>" header value
|
||||
private var sessionId: String? // anon session id (UUID)
|
||||
|
||||
// URLSession with persistent cookie storage
|
||||
private let session: URLSession = {
|
||||
@@ -51,10 +50,6 @@ actor APIClient {
|
||||
}
|
||||
}
|
||||
|
||||
func setSessionId(_ id: String) {
|
||||
sessionId = id
|
||||
}
|
||||
|
||||
// MARK: - Low-level request builder
|
||||
|
||||
private func makeRequest(_ path: String, method: String = "GET", body: Encodable? = nil) throws -> URLRequest {
|
||||
@@ -63,9 +58,7 @@ actor APIClient {
|
||||
// paths like /api/chapter/slug/1. URL(string:) preserves slashes correctly.
|
||||
let urlString = baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
+ "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
print("[APIClient] ▶ \(method) \(urlString)")
|
||||
guard let url = URL(string: urlString) else {
|
||||
print("[APIClient] ✗ Could not construct URL from: \(urlString)")
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
var req = URLRequest(url: url)
|
||||
@@ -75,12 +68,6 @@ actor APIClient {
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try JSONEncoder().encode(body)
|
||||
}
|
||||
// Log cookies being sent
|
||||
if let cookies = HTTPCookieStorage.shared.cookies(for: url), !cookies.isEmpty {
|
||||
print("[APIClient] cookies: \(cookies.map { "\($0.name)=\($0.value.prefix(20))…" })")
|
||||
} else {
|
||||
print("[APIClient] cookies: (none)")
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
@@ -90,49 +77,30 @@ actor APIClient {
|
||||
let req = try makeRequest(path, method: method, body: body)
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
print("[APIClient] ✗ \(path) — not an HTTP response")
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8 data, \(data.count) bytes>"
|
||||
print("[APIClient] ◀ \(http.statusCode) \(path) — \(data.count) bytes")
|
||||
if data.count < 2000 {
|
||||
print("[APIClient] body: \(rawBody)")
|
||||
} else {
|
||||
print("[APIClient] body (first 2000 chars): \(rawBody.prefix(2000))")
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw APIError.httpError(http.statusCode, rawBody)
|
||||
}
|
||||
do {
|
||||
let decoded = try JSONDecoder.iso8601.decode(T.self, from: data)
|
||||
print("[APIClient] ✓ decoded \(T.self) from \(path)")
|
||||
return decoded
|
||||
return try JSONDecoder.iso8601.decode(T.self, from: data)
|
||||
} catch {
|
||||
print("[APIClient] ✗ decode error for \(T.self) from \(path)")
|
||||
print("[APIClient] decode error: \(error)")
|
||||
if let decodingError = error as? DecodingError {
|
||||
switch decodingError {
|
||||
case .typeMismatch(let type, let ctx):
|
||||
print("[APIClient] typeMismatch: expected \(type) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
|
||||
case .valueNotFound(let type, let ctx):
|
||||
print("[APIClient] valueNotFound: \(type) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
|
||||
case .keyNotFound(let key, let ctx):
|
||||
print("[APIClient] keyNotFound: '\(key.stringValue)' at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
|
||||
case .dataCorrupted(let ctx):
|
||||
print("[APIClient] dataCorrupted: \(ctx.debugDescription) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
|
||||
@unknown default:
|
||||
print("[APIClient] unknown decoding error: \(error)")
|
||||
}
|
||||
}
|
||||
throw APIError.decodingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchRaw(_ path: String, method: String = "GET", body: Encodable? = nil) async throws -> (Data, HTTPURLResponse) {
|
||||
/// Like `fetch` but discards the response body — use for endpoints that return 204 No Content.
|
||||
func fetchVoid(_ path: String, method: String = "GET", body: Encodable? = nil) async throws {
|
||||
let req = try makeRequest(path, method: method, body: body)
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
|
||||
return (data, http)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8 data, \(data.count) bytes>"
|
||||
throw APIError.httpError(http.statusCode, rawBody)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auth
|
||||
@@ -158,7 +126,7 @@ actor APIClient {
|
||||
}
|
||||
|
||||
func logout() async throws {
|
||||
let (_, _) = try await fetchRaw("/api/auth/logout", method: "POST")
|
||||
let _: EmptyResponse = try await fetch("/api/auth/logout", method: "POST")
|
||||
await setAuthCookie(nil)
|
||||
}
|
||||
|
||||
@@ -196,13 +164,6 @@ actor APIClient {
|
||||
|
||||
// MARK: - Browse
|
||||
|
||||
struct BrowseParams: Encodable {
|
||||
let page: Int
|
||||
let genre: String
|
||||
let sort: String
|
||||
let status: String
|
||||
}
|
||||
|
||||
func browse(page: Int, genre: String = "all", sort: String = "popular", status: String = "all") async throws -> BrowseResponse {
|
||||
let query = "?page=\(page)&genre=\(genre)&sort=\(sort)&status=\(status)"
|
||||
return try await fetch("/api/browse-page\(query)")
|
||||
@@ -228,6 +189,10 @@ actor APIClient {
|
||||
let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "POST", body: Body(chapter: chapter))
|
||||
}
|
||||
|
||||
func deleteProgress(slug: String) async throws {
|
||||
let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "DELETE")
|
||||
}
|
||||
|
||||
func audioTime(slug: String, chapter: Int) async throws -> Double? {
|
||||
struct Response: Decodable { let audioTime: Double?; enum CodingKeys: String, CodingKey { case audioTime = "audio_time" } }
|
||||
let r: Response = try await fetch("/api/progress/audio-time?slug=\(slug)&chapter=\(chapter)")
|
||||
@@ -316,6 +281,115 @@ actor APIClient {
|
||||
func revokeSession(id: String) async throws {
|
||||
let _: EmptyResponse = try await fetch("/api/sessions/\(id)", method: "DELETE")
|
||||
}
|
||||
|
||||
// MARK: - Avatar
|
||||
|
||||
struct AvatarPresignResponse: Decodable {
|
||||
let uploadURL: String
|
||||
let key: String
|
||||
enum CodingKeys: String, CodingKey { case uploadURL = "upload_url"; case key }
|
||||
}
|
||||
|
||||
struct AvatarResponse: Decodable {
|
||||
let avatarURL: String?
|
||||
enum CodingKeys: String, CodingKey { case avatarURL = "avatar_url" }
|
||||
}
|
||||
|
||||
/// Upload a profile avatar using a two-step presigned PUT flow:
|
||||
/// 1. POST /api/profile/avatar → get a presigned PUT URL + object key
|
||||
/// 2. PUT image bytes directly to MinIO via the presigned URL
|
||||
/// 3. PATCH /api/profile/avatar with the key to record it in PocketBase
|
||||
/// Returns the presigned GET URL for the uploaded avatar.
|
||||
func uploadAvatar(_ imageData: Data, mimeType: String = "image/jpeg") async throws -> String? {
|
||||
// Step 1: request a presigned PUT URL from the SvelteKit server
|
||||
let presign: AvatarPresignResponse = try await fetch(
|
||||
"/api/profile/avatar",
|
||||
method: "POST",
|
||||
body: ["mime_type": mimeType]
|
||||
)
|
||||
|
||||
// Step 2: PUT the image bytes directly to MinIO
|
||||
guard let putURL = URL(string: presign.uploadURL) else { throw APIError.invalidResponse }
|
||||
var putReq = URLRequest(url: putURL)
|
||||
putReq.httpMethod = "PUT"
|
||||
putReq.setValue(mimeType, forHTTPHeaderField: "Content-Type")
|
||||
putReq.httpBody = imageData
|
||||
|
||||
let (_, putResp) = try await session.data(for: putReq)
|
||||
guard let putHttp = putResp as? HTTPURLResponse,
|
||||
(200..<300).contains(putHttp.statusCode) else {
|
||||
let code = (putResp as? HTTPURLResponse)?.statusCode ?? 0
|
||||
throw APIError.httpError(code, "MinIO PUT failed")
|
||||
}
|
||||
|
||||
// Step 3: record the key in PocketBase and get back a presigned GET URL
|
||||
let result: AvatarResponse = try await fetch(
|
||||
"/api/profile/avatar",
|
||||
method: "PATCH",
|
||||
body: ["key": presign.key]
|
||||
)
|
||||
return result.avatarURL
|
||||
}
|
||||
|
||||
/// Fetches a fresh presigned GET URL for the current user's avatar.
|
||||
/// Returns nil if the user has no avatar set.
|
||||
/// Used on cold launch / session restore to convert the stored raw key into a viewable URL.
|
||||
func fetchAvatarPresignedURL() async throws -> String? {
|
||||
let result: AvatarResponse = try await fetch("/api/profile/avatar")
|
||||
return result.avatarURL
|
||||
}
|
||||
|
||||
// MARK: - User Profiles & Subscriptions
|
||||
|
||||
func fetchUserProfile(username: String) async throws -> PublicUserProfile {
|
||||
try await fetch("/api/users/\(username)")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func subscribeUser(username: String) async throws -> Bool {
|
||||
struct Response: Decodable { let subscribed: Bool }
|
||||
let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "POST")
|
||||
return r.subscribed
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func unsubscribeUser(username: String) async throws -> Bool {
|
||||
struct Response: Decodable { let subscribed: Bool }
|
||||
let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "DELETE")
|
||||
return r.subscribed
|
||||
}
|
||||
|
||||
func fetchUserLibrary(username: String) async throws -> PublicUserLibraryResponse {
|
||||
try await fetch("/api/users/\(username)/library")
|
||||
}
|
||||
|
||||
// MARK: - Comments
|
||||
|
||||
func fetchComments(slug: String, sort: String = "top") async throws -> CommentsResponse {
|
||||
try await fetch("/api/comments/\(slug)?sort=\(sort)")
|
||||
}
|
||||
|
||||
struct PostCommentBody: Encodable {
|
||||
let body: String
|
||||
let parent_id: String?
|
||||
}
|
||||
|
||||
func postComment(slug: String, body: String, parentId: String? = nil) async throws -> BookComment {
|
||||
try await fetch("/api/comments/\(slug)", method: "POST", body: PostCommentBody(body: body, parent_id: parentId))
|
||||
}
|
||||
|
||||
struct VoteBody: Encodable { let vote: String }
|
||||
|
||||
/// Cast, change, or toggle-off a vote on a comment.
|
||||
/// Returns the updated BookComment (with refreshed upvotes/downvotes counts).
|
||||
func voteComment(commentId: String, vote: String) async throws -> BookComment {
|
||||
try await fetch("/api/comment/\(commentId)/vote", method: "POST", body: VoteBody(vote: vote))
|
||||
}
|
||||
|
||||
/// Delete a comment (and its replies) by ID. Only the owner can delete.
|
||||
func deleteComment(commentId: String) async throws {
|
||||
try await fetchVoid("/api/comment/\(commentId)", method: "DELETE")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Response types
|
||||
@@ -328,11 +402,21 @@ struct HomeDataResponse: Decodable {
|
||||
let continueReading: [ContinueItem]
|
||||
let recentlyUpdated: [Book]
|
||||
let stats: HomeStats
|
||||
let subscriptionFeed: [SubscriptionFeedItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case continueReading = "continue_reading"
|
||||
case recentlyUpdated = "recently_updated"
|
||||
case stats
|
||||
case subscriptionFeed = "subscription_feed"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
continueReading = try c.decodeIfPresent([ContinueItem].self, forKey: .continueReading) ?? []
|
||||
recentlyUpdated = try c.decodeIfPresent([Book].self, forKey: .recentlyUpdated) ?? []
|
||||
stats = try c.decode(HomeStats.self, forKey: .stats)
|
||||
subscriptionFeed = try c.decodeIfPresent([SubscriptionFeedItem].self, forKey: .subscriptionFeed) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,11 +470,6 @@ struct BrowseResponse: Decodable {
|
||||
let novels: [BrowseNovel]
|
||||
let page: Int
|
||||
let hasNext: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case novels, page
|
||||
case hasNext = "hasNext"
|
||||
}
|
||||
}
|
||||
|
||||
struct BrowseNovel: Decodable, Identifiable, Hashable {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{
|
||||
"color": {
|
||||
"color-space": "srgb",
|
||||
"components": { "alpha": "1.000", "blue": "0.588", "green": "0.467", "red": "1.000" }
|
||||
"components": { "alpha": "1.000", "blue": "0.040", "green": "0.620", "red": "0.960" }
|
||||
},
|
||||
"idiom": "universal"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 6.6 KiB |
@@ -2,22 +2,32 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>LibNovel</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>LibNovel</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>cc.kalekber.libnovel</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>LibNovel</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1000</string>
|
||||
<key>LIBNOVEL_BASE_URL</key>
|
||||
<string>$(LIBNOVEL_BASE_URL)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
@@ -31,13 +41,5 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>LIBNOVEL_BASE_URL</key>
|
||||
<string>$(LIBNOVEL_BASE_URL)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -2,6 +2,20 @@ import Foundation
|
||||
import AVFoundation
|
||||
import MediaPlayer
|
||||
import Combine
|
||||
import Kingfisher
|
||||
|
||||
// MARK: - PlaybackProgress
|
||||
// Isolated ObservableObject for high-frequency playback state (currentTime,
|
||||
// duration, isPlaying). Keeping these separate from AudioPlayerService means
|
||||
// the 0.5-second time-observer ticks only invalidate views that explicitly
|
||||
// observe PlaybackProgress — menus and other stable UI are unaffected.
|
||||
|
||||
@MainActor
|
||||
final class PlaybackProgress: ObservableObject {
|
||||
@Published var currentTime: Double = 0
|
||||
@Published var duration: Double = 0
|
||||
@Published var isPlaying: Bool = false
|
||||
}
|
||||
|
||||
// MARK: - AudioPlayerService
|
||||
// Central singleton that owns AVPlayer, drives audio state, handles lock-screen
|
||||
@@ -27,14 +41,33 @@ final class AudioPlayerService: ObservableObject {
|
||||
@Published var errorMessage: String = ""
|
||||
@Published var generationProgress: Double = 0
|
||||
|
||||
@Published var currentTime: Double = 0
|
||||
@Published var duration: Double = 0
|
||||
@Published var isPlaying: Bool = false
|
||||
/// High-frequency playback state (currentTime / duration / isPlaying).
|
||||
/// Views that only need the seek bar or play-pause button should observe
|
||||
/// this directly so they don't trigger re-renders of menu-bearing parents.
|
||||
let progress = PlaybackProgress()
|
||||
|
||||
// Convenience forwarders so non-view call sites keep compiling unchanged.
|
||||
var currentTime: Double {
|
||||
get { progress.currentTime }
|
||||
set { progress.currentTime = newValue }
|
||||
}
|
||||
var duration: Double {
|
||||
get { progress.duration }
|
||||
set { progress.duration = newValue }
|
||||
}
|
||||
var isPlaying: Bool {
|
||||
get { progress.isPlaying }
|
||||
set { progress.isPlaying = newValue }
|
||||
}
|
||||
|
||||
@Published var autoNext: Bool = false
|
||||
@Published var nextChapter: Int? = nil
|
||||
|
||||
@Published var sleepTimerOption: SleepTimerOption = .off
|
||||
@Published var prevChapter: Int? = nil
|
||||
|
||||
@Published var sleepTimer: SleepTimerOption? = nil
|
||||
/// Human-readable countdown string shown in the full player near the moon button.
|
||||
/// e.g. "38:12" for minute-based, "2 ch left" for chapter-based, "" when off.
|
||||
@Published var sleepTimerRemainingText: String = ""
|
||||
|
||||
@Published var nextPrefetchStatus: NextPrefetchStatus = .none
|
||||
@Published var nextAudioURL: String = ""
|
||||
@@ -58,13 +91,18 @@ final class AudioPlayerService: ObservableObject {
|
||||
private var generationTask: Task<Void, Never>?
|
||||
private var prefetchTask: Task<Void, Never>?
|
||||
|
||||
private var sleepTimer: Timer?
|
||||
private var sleepRemainingChapters: Int?
|
||||
|
||||
// Cached cover image — downloaded once per chapter load, reused on every
|
||||
// updateNowPlaying() call so we don't re-download on every play/pause/seek.
|
||||
private var cachedCoverArtwork: MPMediaItemArtwork?
|
||||
private var cachedCoverURL: String = ""
|
||||
|
||||
// Sleep timer tracking
|
||||
private var sleepTimerTask: Task<Void, Never>?
|
||||
private var sleepTimerStartChapter: Int = 0
|
||||
/// Absolute deadline for minute-based timers (nil when not active or chapter-based).
|
||||
private var sleepTimerDeadline: Date? = nil
|
||||
/// 1-second tick task that keeps sleepTimerRemainingText up-to-date.
|
||||
private var sleepTimerCountdownTask: Task<Void, Never>? = nil
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
@@ -78,7 +116,7 @@ final class AudioPlayerService: ObservableObject {
|
||||
/// Load audio for a specific chapter. Triggers TTS generation if not cached.
|
||||
func load(slug: String, chapter: Int, chapterTitle: String,
|
||||
bookTitle: String, coverURL: String, voice: String, speed: Double,
|
||||
chapters: [ChapterIndexBrief], nextChapter: Int?) {
|
||||
chapters: [ChapterIndexBrief], nextChapter: Int?, prevChapter: Int?) {
|
||||
generationTask?.cancel()
|
||||
prefetchTask?.cancel()
|
||||
stop()
|
||||
@@ -92,9 +130,15 @@ final class AudioPlayerService: ObservableObject {
|
||||
self.speed = speed
|
||||
self.chapters = chapters
|
||||
self.nextChapter = nextChapter
|
||||
self.prevChapter = prevChapter
|
||||
self.nextPrefetchStatus = .none
|
||||
self.nextAudioURL = ""
|
||||
self.nextPrefetchedChapter = nil
|
||||
|
||||
// Reset sleep timer start chapter if it's a chapter-based timer
|
||||
if case .chapters = sleepTimer {
|
||||
sleepTimerStartChapter = chapter
|
||||
}
|
||||
|
||||
status = .generating
|
||||
generationProgress = 0
|
||||
@@ -144,6 +188,69 @@ final class AudioPlayerService: ObservableObject {
|
||||
if isPlaying { player?.rate = Float(newSpeed) }
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
func setSleepTimer(_ option: SleepTimerOption?) {
|
||||
// Cancel existing timer + countdown
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerTask = nil
|
||||
sleepTimerCountdownTask?.cancel()
|
||||
sleepTimerCountdownTask = nil
|
||||
sleepTimerDeadline = nil
|
||||
|
||||
sleepTimer = option
|
||||
|
||||
guard let option else {
|
||||
sleepTimerRemainingText = ""
|
||||
return
|
||||
}
|
||||
|
||||
// Start timer based on option
|
||||
switch option {
|
||||
case .chapters(let count):
|
||||
sleepTimerStartChapter = chapter
|
||||
// Update display immediately; chapter changes are tracked in handlePlaybackFinished.
|
||||
updateChapterTimerLabel(chaptersRemaining: count)
|
||||
|
||||
case .minutes(let minutes):
|
||||
let deadline = Date().addingTimeInterval(Double(minutes) * 60)
|
||||
sleepTimerDeadline = deadline
|
||||
// Stop playback when the deadline is reached.
|
||||
sleepTimerTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(minutes) * 60 * 1_000_000_000)
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
self.stop()
|
||||
self.sleepTimer = nil
|
||||
self.sleepTimerRemainingText = ""
|
||||
}
|
||||
}
|
||||
// 1-second tick to keep the countdown label fresh.
|
||||
sleepTimerCountdownTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
guard let deadline = self.sleepTimerDeadline else { return }
|
||||
let remaining = max(0, deadline.timeIntervalSinceNow)
|
||||
self.sleepTimerRemainingText = Self.formatCountdown(remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Set initial label without waiting for the first tick.
|
||||
sleepTimerRemainingText = Self.formatCountdown(Double(minutes) * 60)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateChapterTimerLabel(chaptersRemaining: Int) {
|
||||
sleepTimerRemainingText = chaptersRemaining == 1 ? "1 ch left" : "\(chaptersRemaining) ch left"
|
||||
}
|
||||
|
||||
private static func formatCountdown(_ seconds: Double) -> String {
|
||||
let s = Int(max(0, seconds))
|
||||
let m = s / 60
|
||||
let sec = s % 60
|
||||
return "\(m):\(String(format: "%02d", sec))"
|
||||
}
|
||||
|
||||
func stop() {
|
||||
player?.pause()
|
||||
@@ -153,49 +260,15 @@ final class AudioPlayerService: ObservableObject {
|
||||
duration = 0
|
||||
audioURL = ""
|
||||
status = .idle
|
||||
clearSleepTimerIfNeeded()
|
||||
}
|
||||
|
||||
func playChapter(number: Int) {
|
||||
guard !slug.isEmpty else { return }
|
||||
let title = chapters.first(where: { $0.number == number })?.title ?? ""
|
||||
let next = chapters.first(where: { $0.number > number })?.number
|
||||
load(
|
||||
slug: slug,
|
||||
chapter: number,
|
||||
chapterTitle: title,
|
||||
bookTitle: bookTitle,
|
||||
coverURL: coverURL,
|
||||
voice: voice,
|
||||
speed: speed,
|
||||
chapters: chapters,
|
||||
nextChapter: next
|
||||
)
|
||||
}
|
||||
|
||||
func setSleepTimer(_ option: SleepTimerOption) {
|
||||
sleepTimer?.invalidate()
|
||||
|
||||
// Cancel sleep timer + countdown
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerTask = nil
|
||||
sleepTimerCountdownTask?.cancel()
|
||||
sleepTimerCountdownTask = nil
|
||||
sleepTimerDeadline = nil
|
||||
sleepTimer = nil
|
||||
sleepRemainingChapters = nil
|
||||
sleepTimerOption = option
|
||||
|
||||
switch option {
|
||||
case .off:
|
||||
break
|
||||
case .endOfChapter:
|
||||
sleepRemainingChapters = 1
|
||||
case .chapters(let count):
|
||||
sleepRemainingChapters = max(1, count)
|
||||
case .minutes(let minutes):
|
||||
let seconds = max(60, minutes * 60)
|
||||
sleepTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(seconds), repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.stop()
|
||||
self?.sleepTimerOption = .off
|
||||
self?.sleepRemainingChapters = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
sleepTimerRemainingText = ""
|
||||
}
|
||||
|
||||
// MARK: - Audio generation
|
||||
@@ -357,51 +430,38 @@ final class AudioPlayerService: ObservableObject {
|
||||
playerItem = nil
|
||||
}
|
||||
|
||||
private func clearSleepTimerIfNeeded() {
|
||||
sleepTimer?.invalidate()
|
||||
sleepTimer = nil
|
||||
sleepRemainingChapters = nil
|
||||
sleepTimerOption = .off
|
||||
}
|
||||
|
||||
private func handlePlaybackFinished() {
|
||||
isPlaying = false
|
||||
|
||||
var shouldAutoNext = autoNext
|
||||
var didTriggerSleepStop = false
|
||||
if let remaining = sleepRemainingChapters {
|
||||
let updated = remaining - 1
|
||||
if updated <= 0 {
|
||||
sleepRemainingChapters = nil
|
||||
sleepTimerOption = .off
|
||||
didTriggerSleepStop = true
|
||||
shouldAutoNext = false
|
||||
} else {
|
||||
sleepRemainingChapters = updated
|
||||
}
|
||||
}
|
||||
|
||||
if didTriggerSleepStop {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
guard let next = nextChapter else { return }
|
||||
|
||||
// Check chapter-based sleep timer
|
||||
if case .chapters(let count) = sleepTimer {
|
||||
let chaptersPlayed = chapter - sleepTimerStartChapter + 1
|
||||
if chaptersPlayed >= count {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
// Update the remaining chapters label.
|
||||
let remaining = count - chaptersPlayed
|
||||
updateChapterTimerLabel(chaptersRemaining: remaining)
|
||||
}
|
||||
|
||||
// Always notify the view that the chapter finished (it may update UI).
|
||||
NotificationCenter.default.post(
|
||||
name: .audioDidFinishChapter,
|
||||
object: nil,
|
||||
userInfo: ["next": next, "autoNext": shouldAutoNext]
|
||||
userInfo: ["next": next, "autoNext": autoNext]
|
||||
)
|
||||
|
||||
// If autoNext is on, load the next chapter internally right away.
|
||||
// We already have the metadata in `chapters`, so we can reconstruct
|
||||
// everything without waiting for the view to navigate.
|
||||
guard shouldAutoNext else { return }
|
||||
guard autoNext else { return }
|
||||
|
||||
let nextTitle = chapters.first(where: { $0.number == next })?.title ?? ""
|
||||
let nextNextChapter = chapters.first(where: { $0.number > next })?.number
|
||||
let nextPrevChapter: Int? = chapter // Current chapter becomes previous for the next one
|
||||
|
||||
// If we already prefetched a URL for the next chapter, skip straight to
|
||||
// playback and kick off generation in the background for the one after.
|
||||
@@ -412,12 +472,18 @@ final class AudioPlayerService: ObservableObject {
|
||||
chapter = next
|
||||
chapterTitle = nextTitle
|
||||
nextChapter = nextNextChapter
|
||||
prevChapter = nextPrevChapter
|
||||
nextPrefetchStatus = .none
|
||||
nextAudioURL = ""
|
||||
nextPrefetchedChapter = nil
|
||||
audioURL = url
|
||||
status = .ready
|
||||
generationProgress = 100
|
||||
|
||||
// Update sleep timer start chapter if using chapter-based timer
|
||||
if case .chapters = sleepTimer {
|
||||
sleepTimerStartChapter = next
|
||||
}
|
||||
|
||||
generationTask = Task {
|
||||
await playURL(url)
|
||||
@@ -434,7 +500,8 @@ final class AudioPlayerService: ObservableObject {
|
||||
voice: voice,
|
||||
speed: speed,
|
||||
chapters: chapters,
|
||||
nextChapter: nextNextChapter
|
||||
nextChapter: nextNextChapter,
|
||||
prevChapter: nextPrevChapter
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -443,14 +510,17 @@ final class AudioPlayerService: ObservableObject {
|
||||
|
||||
private func prefetchCoverArtwork(from urlString: String) {
|
||||
guard !urlString.isEmpty, let url = URL(string: urlString) else { return }
|
||||
URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
|
||||
guard let self, let data, let image = UIImage(data: data) else { return }
|
||||
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
||||
Task { @MainActor in
|
||||
self.cachedCoverArtwork = artwork
|
||||
self.updateNowPlaying()
|
||||
KingfisherManager.shared.retrieveImage(with: url) { [weak self] result in
|
||||
guard let self else { return }
|
||||
if case .success(let value) = result {
|
||||
let image = value.image
|
||||
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
||||
Task { @MainActor in
|
||||
self.cachedCoverArtwork = artwork
|
||||
self.updateNowPlaying()
|
||||
}
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Audio Session
|
||||
@@ -480,9 +550,9 @@ final class AudioPlayerService: ObservableObject {
|
||||
self?.togglePlayPause()
|
||||
return .success
|
||||
}
|
||||
center.skipForwardCommand.preferredIntervals = [30]
|
||||
center.skipForwardCommand.preferredIntervals = [15]
|
||||
center.skipForwardCommand.addTarget { [weak self] _ in
|
||||
self?.skip(by: 30)
|
||||
self?.skip(by: 15)
|
||||
return .success
|
||||
}
|
||||
center.skipBackwardCommand.preferredIntervals = [15]
|
||||
@@ -535,12 +605,12 @@ enum AudioPlayerStatus: Equatable {
|
||||
}
|
||||
|
||||
enum SleepTimerOption: Equatable {
|
||||
case off
|
||||
case endOfChapter
|
||||
case chapters(Int)
|
||||
case minutes(Int)
|
||||
case chapters(Int) // Stop after N chapters
|
||||
case minutes(Int) // Stop after N minutes
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let audioDidFinishChapter = Notification.Name("audioDidFinishChapter")
|
||||
static let skipToNextChapter = Notification.Name("skipToNextChapter")
|
||||
static let skipToPrevChapter = Notification.Name("skipToPrevChapter")
|
||||
}
|
||||
|
||||
@@ -85,7 +85,14 @@ final class AuthStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Token validation (on cold launch)
|
||||
// MARK: - Token validation
|
||||
|
||||
/// Re-validates the current session and refreshes `user` + `settings`.
|
||||
/// Call this after any operation that may change the user record (e.g. avatar upload).
|
||||
func validateToken() async {
|
||||
guard let token = loadToken() else { return }
|
||||
await validateToken(token)
|
||||
}
|
||||
|
||||
private func validateToken(_ token: String) async {
|
||||
await APIClient.shared.setAuthCookie(token)
|
||||
@@ -93,7 +100,20 @@ final class AuthStore: ObservableObject {
|
||||
do {
|
||||
async let me: AppUser = APIClient.shared.fetch("/api/auth/me")
|
||||
async let s: UserSettings = APIClient.shared.settings()
|
||||
let (restoredUser, restoredSettings) = try await (me, s)
|
||||
var (restoredUser, restoredSettings) = try await (me, s)
|
||||
// /api/auth/me returns the raw MinIO object key for avatar_url, not a presigned URL.
|
||||
// Exchange the key for a fresh presigned GET URL so KFImage can display it.
|
||||
if let key = restoredUser.avatarURL, !key.hasPrefix("http") {
|
||||
if let presignedURL = try? await APIClient.shared.fetchAvatarPresignedURL() {
|
||||
restoredUser = AppUser(
|
||||
id: restoredUser.id,
|
||||
username: restoredUser.username,
|
||||
role: restoredUser.role,
|
||||
created: restoredUser.created,
|
||||
avatarURL: presignedURL
|
||||
)
|
||||
}
|
||||
}
|
||||
user = restoredUser
|
||||
settings = restoredSettings
|
||||
} catch let e as APIError {
|
||||
|
||||
@@ -9,7 +9,6 @@ final class BookDetailViewModel: ObservableObject {
|
||||
@Published var saved: Bool = false
|
||||
@Published var lastChapter: Int?
|
||||
@Published var isLoading = false
|
||||
@Published var chaptersLoading = false
|
||||
@Published var error: String?
|
||||
|
||||
init(slug: String) {
|
||||
|
||||
@@ -50,14 +50,12 @@ final class BrowseViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func loadPage(_ page: Int) async {
|
||||
print("[Browse] loadPage(\(page)) genre=\(genre) sort=\(sort) status=\(status)")
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let result = try await APIClient.shared.browse(
|
||||
page: page, genre: genre, sort: sort, status: status
|
||||
)
|
||||
print("[Browse] ✓ page \(page) — \(result.novels.count) novels, hasNext=\(result.hasNext)")
|
||||
if page == 1 {
|
||||
novels = result.novels
|
||||
} else {
|
||||
@@ -67,7 +65,6 @@ final class BrowseViewModel: ObservableObject {
|
||||
currentPage = page
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
print("[Browse] ✗ error on page \(page): \(error)")
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,34 +3,38 @@ import Foundation
|
||||
@MainActor
|
||||
final class ChapterReaderViewModel: ObservableObject {
|
||||
let slug: String
|
||||
let chapter: Int
|
||||
private(set) var chapter: Int
|
||||
|
||||
@Published var content: ChapterResponse?
|
||||
@Published var isLoading = false
|
||||
@Published var error: String?
|
||||
@Published var navigateTo: Int? // set to trigger navigation to next chapter
|
||||
|
||||
init(slug: String, chapter: Int) {
|
||||
self.slug = slug
|
||||
self.chapter = chapter
|
||||
}
|
||||
|
||||
/// Switch to a different chapter in-place: resets state and updates `chapter`
|
||||
/// so that `.task(id: currentChapter)` in the View re-fires `load()`.
|
||||
func switchChapter(to newChapter: Int) {
|
||||
guard newChapter != chapter else { return }
|
||||
chapter = newChapter
|
||||
content = nil
|
||||
error = nil
|
||||
}
|
||||
|
||||
func load() async {
|
||||
print("[ChapterReader] load() slug=\(slug) chapter=\(chapter)")
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
content = try await APIClient.shared.chapterContent(slug: slug, chapter: chapter)
|
||||
print("[ChapterReader] ✓ loaded chapter \(chapter), html length=\(content?.html.count ?? 0)")
|
||||
// Record reading progress
|
||||
try? await APIClient.shared.setProgress(slug: slug, chapter: chapter)
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
print("[ChapterReader] ✗ error: \(error)")
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
print("[ChapterReader] done — isLoading=false, content=\(content != nil), error=\(String(describing: self.error))")
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
@@ -47,6 +51,7 @@ final class ChapterReaderViewModel: ObservableObject {
|
||||
audioPlayer.togglePlayPause()
|
||||
} else {
|
||||
let nextChapter: Int? = content.next
|
||||
let prevChapter: Int? = content.prev
|
||||
audioPlayer.load(
|
||||
slug: slug,
|
||||
chapter: chapter,
|
||||
@@ -56,7 +61,8 @@ final class ChapterReaderViewModel: ObservableObject {
|
||||
voice: settings.voice,
|
||||
speed: settings.speed,
|
||||
chapters: content.chapters,
|
||||
nextChapter: nextChapter
|
||||
nextChapter: nextChapter,
|
||||
prevChapter: prevChapter
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ final class HomeViewModel: ObservableObject {
|
||||
@Published var continueReading: [ContinueReadingItem] = []
|
||||
@Published var recentlyUpdated: [Book] = []
|
||||
@Published var stats: HomeStats?
|
||||
@Published var subscriptionFeed: [SubscriptionFeedItem] = []
|
||||
@Published var isLoading = false
|
||||
@Published var error: String?
|
||||
|
||||
@@ -18,6 +19,7 @@ final class HomeViewModel: ObservableObject {
|
||||
}
|
||||
recentlyUpdated = data.recentlyUpdated
|
||||
stats = data.stats
|
||||
subscriptionFeed = data.subscriptionFeed
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
|
||||
87
ios/LibNovel/LibNovel/ViewModels/UserProfileViewModel.swift
Normal file
87
ios/LibNovel/LibNovel/ViewModels/UserProfileViewModel.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class UserProfileViewModel: ObservableObject {
|
||||
let username: String
|
||||
|
||||
@Published var profile: PublicUserProfile?
|
||||
@Published var currentlyReading: [PublicLibraryItem] = []
|
||||
@Published var library: [PublicLibraryItem] = []
|
||||
@Published var isLoading = false
|
||||
@Published var isTogglingSubscribe = false
|
||||
@Published var error: String?
|
||||
|
||||
init(username: String) {
|
||||
self.username = username
|
||||
}
|
||||
|
||||
func load() async {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
async let profileFetch = APIClient.shared.fetchUserProfile(username: username)
|
||||
async let libraryFetch = APIClient.shared.fetchUserLibrary(username: username)
|
||||
let (p, lib) = try await (profileFetch, libraryFetch)
|
||||
profile = p
|
||||
currentlyReading = lib.currentlyReading
|
||||
library = lib.library
|
||||
} catch let apiError as APIError {
|
||||
switch apiError {
|
||||
case .httpError(404, _): error = "User not found."
|
||||
default: error = apiError.localizedDescription
|
||||
}
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func toggleSubscribe() async {
|
||||
guard let p = profile, !p.isSelf, !isTogglingSubscribe else { return }
|
||||
isTogglingSubscribe = true
|
||||
defer { isTogglingSubscribe = false }
|
||||
do {
|
||||
if p.isSubscribed {
|
||||
try await APIClient.shared.unsubscribeUser(username: username)
|
||||
profile = PublicUserProfile(
|
||||
id: p.id, username: p.username, avatarUrl: p.avatarUrl,
|
||||
created: p.created,
|
||||
followerCount: max(0, p.followerCount - 1),
|
||||
followingCount: p.followingCount,
|
||||
isSubscribed: false, isSelf: p.isSelf
|
||||
)
|
||||
} else {
|
||||
try await APIClient.shared.subscribeUser(username: username)
|
||||
profile = PublicUserProfile(
|
||||
id: p.id, username: p.username, avatarUrl: p.avatarUrl,
|
||||
created: p.created,
|
||||
followerCount: p.followerCount + 1,
|
||||
followingCount: p.followingCount,
|
||||
isSubscribed: true, isSelf: p.isSelf
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience memberwise init for PublicUserProfile (used in optimistic updates)
|
||||
|
||||
private extension PublicUserProfile {
|
||||
init(id: String, username: String, avatarUrl: String?, created: String,
|
||||
followerCount: Int, followingCount: Int, isSubscribed: Bool, isSelf: Bool) {
|
||||
// Encode then decode to go through the standard Decodable path without duplicating code
|
||||
var dict: [String: Any] = [
|
||||
"id": id, "username": username, "created": created,
|
||||
"followerCount": followerCount, "followingCount": followingCount,
|
||||
"isSubscribed": isSubscribed, "isSelf": isSelf
|
||||
]
|
||||
if let url = avatarUrl { dict["avatarUrl"] = url }
|
||||
let data = try! JSONSerialization.data(withJSONObject: dict)
|
||||
self = try! JSONDecoder().decode(PublicUserProfile.self, from: data)
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,7 @@ struct AuthView: View {
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
}
|
||||
.onChange(of: mode) { _, _ in
|
||||
authStore.error = nil
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct BookDetailView: View {
|
||||
let slug: String
|
||||
@@ -6,8 +7,7 @@ struct BookDetailView: View {
|
||||
@EnvironmentObject var authStore: AuthStore
|
||||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||||
@State private var summaryExpanded = false
|
||||
@State private var chapterPage = 0
|
||||
private let pageSize = 50
|
||||
@State private var showChapters = false
|
||||
|
||||
init(slug: String) {
|
||||
self.slug = slug
|
||||
@@ -15,24 +15,36 @@ struct BookDetailView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
if vm.isLoading {
|
||||
ProgressView().frame(maxWidth: .infinity).padding(.top, 80)
|
||||
} else if let book = vm.book {
|
||||
ZStack(alignment: .top) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
heroSection(book: book)
|
||||
Divider().padding(.vertical, 8)
|
||||
chapterSection(book: book)
|
||||
if vm.isLoading {
|
||||
ProgressView().frame(maxWidth: .infinity).padding(.top, 120)
|
||||
} else if let book = vm.book {
|
||||
heroSection(book: book)
|
||||
metaSection(book: book)
|
||||
Divider().padding(.horizontal)
|
||||
chaptersRow(book: book)
|
||||
Divider().padding(.horizontal)
|
||||
CommentsView(slug: slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(edges: .top)
|
||||
}
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.appNavigationDestination()
|
||||
.toolbar { bookmarkButton }
|
||||
.task { await vm.load() }
|
||||
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
|
||||
Button("OK") { vm.error = nil }
|
||||
} message: { Text(vm.error ?? "") }
|
||||
.errorAlert($vm.error)
|
||||
.sheet(isPresented: $showChapters) {
|
||||
BookChaptersSheet(
|
||||
slug: slug,
|
||||
chapters: vm.chapters,
|
||||
lastChapter: vm.lastChapter,
|
||||
totalChapters: vm.book?.totalChapters ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hero
|
||||
@@ -40,139 +52,191 @@ struct BookDetailView: View {
|
||||
@ViewBuilder
|
||||
private func heroSection(book: Book) -> some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
// Blurred cover background — use plain colour placeholder to avoid
|
||||
// the rounded-rect loading indicator showing through the blur.
|
||||
AsyncCoverImage(url: book.cover, isBackground: true)
|
||||
// Full-bleed blurred background
|
||||
KFImage(URL(string: book.cover))
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 260)
|
||||
.blur(radius: 20)
|
||||
.frame(height: 320)
|
||||
.blur(radius: 24)
|
||||
.clipped()
|
||||
.overlay(Color.black.opacity(0.45))
|
||||
.overlay(
|
||||
LinearGradient(
|
||||
colors: [.black.opacity(0.15), .black.opacity(0.68)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
|
||||
HStack(alignment: .bottom, spacing: 14) {
|
||||
AsyncCoverImage(url: book.cover)
|
||||
.frame(width: 110, height: 160)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.shadow(radius: 8)
|
||||
VStack(spacing: 16) {
|
||||
KFImage(URL(string: book.cover))
|
||||
.resizable()
|
||||
.placeholder {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(.systemGray5))
|
||||
}
|
||||
.scaledToFill()
|
||||
.frame(width: 130, height: 188)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(color: .black.opacity(0.55), radius: 18, x: 0, y: 10)
|
||||
.shadow(color: .black.opacity(0.3), radius: 6, x: 0, y: 3)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
VStack(spacing: 6) {
|
||||
Text(book.title)
|
||||
.font(.headline)
|
||||
.font(.title3.bold())
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(3)
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
Text(book.author)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
HStack {
|
||||
TagChip(label: book.status).colorScheme(.dark)
|
||||
ForEach(book.genres.prefix(2), id: \.self) {
|
||||
TagChip(label: $0).colorScheme(.dark)
|
||||
.foregroundStyle(.white.opacity(0.75))
|
||||
}
|
||||
|
||||
if !book.genres.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(book.genres.prefix(3), id: \.self) { genre in
|
||||
TagChip(label: genre).colorScheme(.dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if !book.status.isEmpty {
|
||||
StatusBadge(status: book.status)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 16)
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
// Summary
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(book.summary)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(summaryExpanded ? nil : 4)
|
||||
if book.summary.count > 200 {
|
||||
Button(summaryExpanded ? "Less" : "More") {
|
||||
withAnimation { summaryExpanded.toggle() }
|
||||
}
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
// CTA buttons
|
||||
HStack(spacing: 10) {
|
||||
if let last = vm.lastChapter, last > 0 {
|
||||
NavigationLink(value: NavDestination.chapter(slug, last)) {
|
||||
Label("Continue Ch.\(last)", systemImage: "play.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
|
||||
NavigationLink(value: NavDestination.chapter(slug, 1)) {
|
||||
Label("From Ch.1", systemImage: "arrow.counterclockwise")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.secondary)
|
||||
} else {
|
||||
NavigationLink(value: NavDestination.chapter(slug, 1)) {
|
||||
Label("Start Reading", systemImage: "book.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
.frame(minHeight: 320)
|
||||
}
|
||||
|
||||
// MARK: - Chapter list
|
||||
// MARK: - Meta section (stats + summary + CTAs)
|
||||
|
||||
@ViewBuilder
|
||||
private func chapterSection(book: Book) -> some View {
|
||||
let chapters = vm.chapters
|
||||
let total = chapters.count
|
||||
let start = chapterPage * pageSize
|
||||
let end = min(start + pageSize, total)
|
||||
let pageChapters = Array(chapters[start..<end])
|
||||
|
||||
private func metaSection(book: Book) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text("Chapters")
|
||||
.font(.title3.bold())
|
||||
Spacer()
|
||||
if total > 0 {
|
||||
Text("\(start + 1)–\(end) of \(total)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
// Quick stats row
|
||||
HStack(spacing: 0) {
|
||||
MetaStat(value: "\(book.totalChapters)", label: "Chapters", icon: "doc.text")
|
||||
Divider().frame(height: 36)
|
||||
MetaStat(
|
||||
value: book.status.capitalized.isEmpty ? "—" : book.status.capitalized,
|
||||
label: "Status", icon: "flag"
|
||||
)
|
||||
if book.ranking > 0 {
|
||||
Divider().frame(height: 36)
|
||||
MetaStat(value: "#\(book.ranking)", label: "Rank", icon: "chart.bar.fill")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Divider().padding(.horizontal)
|
||||
|
||||
// Summary
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("About")
|
||||
.font(.headline)
|
||||
|
||||
Text(book.summary)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(summaryExpanded ? nil : 4)
|
||||
.animation(.easeInOut(duration: 0.2), value: summaryExpanded)
|
||||
|
||||
if book.summary.count > 200 {
|
||||
Button(summaryExpanded ? "Less" : "More") {
|
||||
withAnimation { summaryExpanded.toggle() }
|
||||
}
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.vertical, 16)
|
||||
|
||||
if vm.chaptersLoading {
|
||||
ProgressView().frame(maxWidth: .infinity).padding()
|
||||
} else {
|
||||
ForEach(pageChapters) { ch in
|
||||
NavigationLink(value: NavDestination.chapter(slug, ch.number)) {
|
||||
ChapterRow(chapter: ch, isCurrent: ch.number == vm.lastChapter)
|
||||
Divider().padding(.horizontal)
|
||||
|
||||
// CTA buttons
|
||||
HStack(spacing: 10) {
|
||||
if let last = vm.lastChapter, last > 0 {
|
||||
NavigationLink(value: NavDestination.chapter(slug, last)) {
|
||||
Label("Continue Ch.\(last)", systemImage: "play.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Divider().padding(.leading)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
|
||||
// Pagination
|
||||
if total > pageSize {
|
||||
HStack {
|
||||
Button("Previous") { chapterPage -= 1 }
|
||||
.disabled(chapterPage == 0)
|
||||
Spacer()
|
||||
Button("Next") { chapterPage += 1 }
|
||||
.disabled(end >= total)
|
||||
NavigationLink(value: NavDestination.chapter(slug, 1)) {
|
||||
Label("From Ch.1", systemImage: "arrow.counterclockwise")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.secondary)
|
||||
} else {
|
||||
NavigationLink(value: NavDestination.chapter(slug, 1)) {
|
||||
Label("Start Reading", systemImage: "book.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.padding()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toolbar bookmark
|
||||
// MARK: - Compact chapters row (tap → sheet)
|
||||
|
||||
@ViewBuilder
|
||||
private func chaptersRow(book: Book) -> some View {
|
||||
Button {
|
||||
showChapters = true
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "list.number")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.amber)
|
||||
.frame(width: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Chapters")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
if !vm.chapters.isEmpty {
|
||||
let last = vm.lastChapter
|
||||
let total = vm.chapters.count
|
||||
Text(last != nil && last! > 0
|
||||
? "Reading Ch.\(last!) of \(total)"
|
||||
: "\(total) chapter\(total == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if vm.isLoading {
|
||||
Text("Loading…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - Bookmark toolbar
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var bookmarkButton: some ToolbarContent {
|
||||
@@ -187,38 +251,241 @@ struct BookDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chapters list sheet
|
||||
|
||||
struct BookChaptersSheet: View {
|
||||
let slug: String
|
||||
let chapters: [ChapterIndex]
|
||||
let lastChapter: Int?
|
||||
let totalChapters: Int
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var searchText = ""
|
||||
@State private var scrollToCurrentOnAppear = true
|
||||
|
||||
private var filtered: [ChapterIndex] {
|
||||
guard !searchText.isEmpty else { return chapters }
|
||||
let q = searchText.lowercased()
|
||||
return chapters.filter {
|
||||
"chapter \($0.number)".contains(q) ||
|
||||
$0.title.lowercased().contains(q)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// Search bar
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
|
||||
TextField("Search chapters…", text: $searchText)
|
||||
.autocorrectionDisabled()
|
||||
if !searchText.isEmpty {
|
||||
Button { searchText = "" } label: {
|
||||
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
|
||||
Divider()
|
||||
|
||||
// Jump-to-current banner (shown when user has progress and not searching)
|
||||
if let last = lastChapter, last > 0, searchText.isEmpty {
|
||||
Button {
|
||||
scrollToCurrentOnAppear = true
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
.foregroundStyle(.amber)
|
||||
Text("Jump to Ch.\(last)")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.amber)
|
||||
Spacer()
|
||||
let pct = totalChapters > 0
|
||||
? Int(Double(last) / Double(totalChapters) * 100)
|
||||
: 0
|
||||
Text("\(pct)% read")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Divider()
|
||||
}
|
||||
|
||||
if chapters.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if filtered.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No chapters match \"\(searchText)\"")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollViewReader { proxy in
|
||||
List {
|
||||
ForEach(filtered) { ch in
|
||||
NavigationLink(value: NavDestination.chapter(slug, ch.number)) {
|
||||
ChapterRow(
|
||||
chapter: ch,
|
||||
isCurrent: ch.number == lastChapter,
|
||||
totalChapters: chapters.count
|
||||
)
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16))
|
||||
.id(ch.number)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.appNavigationDestination()
|
||||
.onAppear {
|
||||
if scrollToCurrentOnAppear, let last = lastChapter, last > 0 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||
withAnimation {
|
||||
proxy.scrollTo(last, anchor: .center)
|
||||
}
|
||||
}
|
||||
scrollToCurrentOnAppear = false
|
||||
}
|
||||
}
|
||||
.onChange(of: scrollToCurrentOnAppear) { _, jump in
|
||||
if jump, let last = lastChapter, last > 0 {
|
||||
withAnimation {
|
||||
proxy.scrollTo(last, anchor: .center)
|
||||
}
|
||||
scrollToCurrentOnAppear = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Chapters")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chapter row (reused by sheet)
|
||||
|
||||
private struct ChapterRow: View {
|
||||
let chapter: ChapterIndex
|
||||
let isCurrent: Bool
|
||||
let totalChapters: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Chapter \(chapter.number)")
|
||||
.font(.subheadline)
|
||||
.fontWeight(isCurrent ? .bold : .regular)
|
||||
.foregroundStyle(isCurrent ? .amber : .primary)
|
||||
if !chapter.title.isEmpty && chapter.title != "Chapter \(chapter.number)" {
|
||||
Text(chapter.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
// Number badge
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isCurrent ? Color.amber : Color(.systemGray6))
|
||||
Text("\(chapter.number)")
|
||||
.font(.caption2.bold().monospacedDigit())
|
||||
.foregroundStyle(isCurrent ? .black : .secondary)
|
||||
.minimumScaleFactor(0.6)
|
||||
}
|
||||
Spacer(minLength: 12)
|
||||
HStack(spacing: 6) {
|
||||
.frame(width: 32, height: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
let displayTitle: String = {
|
||||
let stripped = chapter.title.strippingTrailingDate()
|
||||
if stripped.isEmpty || stripped == "Chapter \(chapter.number)" {
|
||||
return "Chapter \(chapter.number)"
|
||||
}
|
||||
return stripped
|
||||
}()
|
||||
|
||||
Text(displayTitle)
|
||||
.font(.subheadline)
|
||||
.fontWeight(isCurrent ? .semibold : .regular)
|
||||
.foregroundStyle(isCurrent ? .amber : .primary)
|
||||
.lineLimit(1)
|
||||
|
||||
if !chapter.dateLabel.isEmpty {
|
||||
Text(chapter.dateLabel)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.fixedSize()
|
||||
}
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting components
|
||||
|
||||
private struct MetaStat: View {
|
||||
let value: String
|
||||
let label: String
|
||||
let icon: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.amber)
|
||||
Text(value)
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatusBadge: View {
|
||||
let status: String
|
||||
|
||||
private var color: Color {
|
||||
switch status.lowercased() {
|
||||
case "ongoing", "active": return .green
|
||||
case "completed": return .blue
|
||||
case "hiatus": return .orange
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(status.capitalized)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(color.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
643
ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift
Normal file
643
ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift
Normal file
@@ -0,0 +1,643 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - ViewModel
|
||||
|
||||
@MainActor
|
||||
class CommentsViewModel: ObservableObject {
|
||||
let slug: String
|
||||
|
||||
@Published var comments: [BookComment] = []
|
||||
@Published var myVotes: [String: String] = [:] // commentId → "up" | "down"
|
||||
@Published var avatarUrls: [String: String] = [:] // userId → presigned URL
|
||||
@Published var isLoading = true
|
||||
@Published var error: String?
|
||||
|
||||
@Published var newBody = ""
|
||||
@Published var isPosting = false
|
||||
@Published var postError: String?
|
||||
|
||||
@Published var sort: CommentSortOrder = .top
|
||||
|
||||
// Reply state
|
||||
@Published var replyingToId: String? = nil
|
||||
@Published var replyBody = ""
|
||||
@Published var isPostingReply = false
|
||||
@Published var replyError: String?
|
||||
|
||||
private var votingIds: Set<String> = []
|
||||
private var deletingIds: Set<String> = []
|
||||
|
||||
init(slug: String) {
|
||||
self.slug = slug
|
||||
}
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let response = try await APIClient.shared.fetchComments(slug: slug, sort: sort.rawValue)
|
||||
comments = response.comments
|
||||
myVotes = response.myVotes
|
||||
avatarUrls = response.avatarUrls
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func postComment() async {
|
||||
let text = newBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty, !isPosting else { return }
|
||||
if text.count > 2000 {
|
||||
postError = "Comment too long (max 2000 characters)."
|
||||
return
|
||||
}
|
||||
isPosting = true
|
||||
postError = nil
|
||||
do {
|
||||
var created = try await APIClient.shared.postComment(slug: slug, body: text)
|
||||
created.replies = []
|
||||
comments.insert(created, at: 0)
|
||||
newBody = ""
|
||||
} catch let apiError as APIError {
|
||||
switch apiError {
|
||||
case .httpError(401, _): postError = "You must be logged in to comment."
|
||||
default: postError = apiError.localizedDescription
|
||||
}
|
||||
} catch {
|
||||
postError = error.localizedDescription
|
||||
}
|
||||
isPosting = false
|
||||
}
|
||||
|
||||
func postReply(parentId: String) async {
|
||||
let text = replyBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty, !isPostingReply else { return }
|
||||
if text.count > 2000 {
|
||||
replyError = "Reply too long (max 2000 characters)."
|
||||
return
|
||||
}
|
||||
isPostingReply = true
|
||||
replyError = nil
|
||||
do {
|
||||
let created = try await APIClient.shared.postComment(slug: slug, body: text, parentId: parentId)
|
||||
if let idx = comments.firstIndex(where: { $0.id == parentId }) {
|
||||
var parent = comments[idx]
|
||||
var replies = parent.replies ?? []
|
||||
replies.append(created)
|
||||
parent.replies = replies
|
||||
comments[idx] = parent
|
||||
}
|
||||
replyBody = ""
|
||||
replyingToId = nil
|
||||
} catch let apiError as APIError {
|
||||
switch apiError {
|
||||
case .httpError(401, _): replyError = "You must be logged in to reply."
|
||||
default: replyError = apiError.localizedDescription
|
||||
}
|
||||
} catch {
|
||||
replyError = error.localizedDescription
|
||||
}
|
||||
isPostingReply = false
|
||||
}
|
||||
|
||||
func deleteComment(commentId: String, parentId: String? = nil) async {
|
||||
guard !deletingIds.contains(commentId) else { return }
|
||||
deletingIds.insert(commentId)
|
||||
|
||||
// Optimistic removal — update the UI immediately before the network call
|
||||
var removedComment: BookComment?
|
||||
var removedAtIndex: Int?
|
||||
if let parentId {
|
||||
if let idx = comments.firstIndex(where: { $0.id == parentId }) {
|
||||
var parent = comments[idx]
|
||||
removedComment = parent.replies?.first(where: { $0.id == commentId })
|
||||
removedAtIndex = idx
|
||||
parent.replies = (parent.replies ?? []).filter { $0.id != commentId }
|
||||
comments[idx] = parent
|
||||
}
|
||||
} else {
|
||||
removedAtIndex = comments.firstIndex(where: { $0.id == commentId })
|
||||
removedComment = removedAtIndex.map { comments[$0] }
|
||||
comments.removeAll { $0.id == commentId }
|
||||
}
|
||||
|
||||
do {
|
||||
try await APIClient.shared.deleteComment(commentId: commentId)
|
||||
} catch {
|
||||
// Revert the optimistic removal on failure
|
||||
if let removed = removedComment {
|
||||
if let parentId, let idx = removedAtIndex {
|
||||
var parent = comments[idx]
|
||||
var replies = parent.replies ?? []
|
||||
replies.append(removed)
|
||||
replies.sort { $0.created < $1.created }
|
||||
parent.replies = replies
|
||||
comments[idx] = parent
|
||||
} else if let idx = removedAtIndex {
|
||||
comments.insert(removed, at: min(idx, comments.count))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deletingIds.remove(commentId)
|
||||
}
|
||||
|
||||
func vote(commentId: String, vote: String, parentId: String? = nil) async {
|
||||
guard !votingIds.contains(commentId) else { return }
|
||||
votingIds.insert(commentId)
|
||||
defer { votingIds.remove(commentId) }
|
||||
do {
|
||||
let updated = try await APIClient.shared.voteComment(commentId: commentId, vote: vote)
|
||||
if let parentId {
|
||||
if let idx = comments.firstIndex(where: { $0.id == parentId }) {
|
||||
var parent = comments[idx]
|
||||
if let rIdx = parent.replies?.firstIndex(where: { $0.id == commentId }) {
|
||||
parent.replies![rIdx] = updated
|
||||
}
|
||||
comments[idx] = parent
|
||||
}
|
||||
} else {
|
||||
if let idx = comments.firstIndex(where: { $0.id == commentId }) {
|
||||
var c = updated
|
||||
c.replies = comments[idx].replies
|
||||
comments[idx] = c
|
||||
}
|
||||
}
|
||||
let prev = myVotes[commentId]
|
||||
if prev == vote {
|
||||
myVotes.removeValue(forKey: commentId)
|
||||
} else {
|
||||
myVotes[commentId] = vote
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore vote errors
|
||||
}
|
||||
}
|
||||
|
||||
func isVoting(_ commentId: String) -> Bool { votingIds.contains(commentId) }
|
||||
func isDeleting(_ commentId: String) -> Bool { deletingIds.contains(commentId) }
|
||||
|
||||
func setSort(_ newSort: CommentSortOrder) {
|
||||
guard newSort != sort else { return }
|
||||
sort = newSort
|
||||
Task { await load() }
|
||||
}
|
||||
}
|
||||
|
||||
enum CommentSortOrder: String, CaseIterable {
|
||||
case top = "top"
|
||||
case new = "new"
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .top: return "Top"
|
||||
case .new: return "New"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CommentsView
|
||||
|
||||
struct CommentsView: View {
|
||||
@StateObject private var vm: CommentsViewModel
|
||||
@EnvironmentObject private var authStore: AuthStore
|
||||
|
||||
init(slug: String) {
|
||||
_vm = StateObject(wrappedValue: CommentsViewModel(slug: slug))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Section header + sort picker
|
||||
HStack {
|
||||
Text("Comments")
|
||||
.font(.headline)
|
||||
let total = vm.comments.reduce(0) { $0 + 1 + ($1.replies?.count ?? 0) }
|
||||
if !vm.isLoading && total > 0 {
|
||||
Text("(\(total))")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
// Sort picker
|
||||
if !vm.isLoading && !vm.comments.isEmpty {
|
||||
Picker("Sort", selection: Binding(
|
||||
get: { vm.sort },
|
||||
set: { vm.setSort($0) }
|
||||
)) {
|
||||
ForEach(CommentSortOrder.allCases, id: \.self) { s in
|
||||
Text(s.label).tag(s)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 120)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 14)
|
||||
|
||||
Divider().padding(.horizontal)
|
||||
|
||||
// Post form
|
||||
postForm
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Divider().padding(.horizontal)
|
||||
|
||||
// Comment list
|
||||
if vm.isLoading {
|
||||
loadingPlaceholder
|
||||
} else if let err = vm.error {
|
||||
Text(err)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.red)
|
||||
.padding()
|
||||
} else if vm.comments.isEmpty {
|
||||
Text("No comments yet. Be the first!")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding()
|
||||
} else {
|
||||
ForEach(vm.comments) { comment in
|
||||
commentThread(comment: comment)
|
||||
Divider().padding(.leading, 16)
|
||||
}
|
||||
}
|
||||
|
||||
Color.clear.frame(height: 16)
|
||||
}
|
||||
.task { await vm.load() }
|
||||
}
|
||||
|
||||
// MARK: - Comment thread (top-level + replies)
|
||||
|
||||
@ViewBuilder
|
||||
private func commentThread(comment: BookComment) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
CommentRow(
|
||||
comment: comment,
|
||||
myVote: vm.myVotes[comment.id],
|
||||
isVoting: vm.isVoting(comment.id),
|
||||
isDeleting: vm.isDeleting(comment.id),
|
||||
isOwner: authStore.user?.id == comment.userId,
|
||||
isLoggedIn: authStore.isAuthenticated,
|
||||
isReplyingTo: vm.replyingToId == comment.id,
|
||||
avatarUrl: vm.avatarUrls[comment.userId],
|
||||
onVote: { v in Task { await vm.vote(commentId: comment.id, vote: v) } },
|
||||
onDelete: { Task { await vm.deleteComment(commentId: comment.id) } },
|
||||
onReply: {
|
||||
if vm.replyingToId == comment.id {
|
||||
vm.replyingToId = nil
|
||||
vm.replyBody = ""
|
||||
vm.replyError = nil
|
||||
} else {
|
||||
vm.replyingToId = comment.id
|
||||
vm.replyBody = ""
|
||||
vm.replyError = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Inline reply form
|
||||
if vm.replyingToId == comment.id {
|
||||
replyForm(parentId: comment.id)
|
||||
.padding(.leading, 32)
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
// Replies
|
||||
if let replies = comment.replies, !replies.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(replies) { reply in
|
||||
CommentRow(
|
||||
comment: reply,
|
||||
myVote: vm.myVotes[reply.id],
|
||||
isVoting: vm.isVoting(reply.id),
|
||||
isDeleting: vm.isDeleting(reply.id),
|
||||
isOwner: authStore.user?.id == reply.userId,
|
||||
isLoggedIn: authStore.isAuthenticated,
|
||||
isReplyingTo: false,
|
||||
isReply: true,
|
||||
avatarUrl: vm.avatarUrls[reply.userId],
|
||||
onVote: { v in Task { await vm.vote(commentId: reply.id, vote: v, parentId: comment.id) } },
|
||||
onDelete: { Task { await vm.deleteComment(commentId: reply.id, parentId: comment.id) } },
|
||||
onReply: nil
|
||||
)
|
||||
if reply.id != replies.last?.id {
|
||||
Divider().padding(.leading, 48)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 24)
|
||||
.overlay(alignment: .leading) {
|
||||
Rectangle()
|
||||
.fill(Color(.systemGray4))
|
||||
.frame(width: 2)
|
||||
.padding(.leading, 16)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reply form
|
||||
|
||||
@ViewBuilder
|
||||
private func replyForm(parentId: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if vm.replyBody.isEmpty {
|
||||
Text("Write a reply…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.top, 6)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
TextEditor(text: $vm.replyBody)
|
||||
.font(.caption)
|
||||
.frame(minHeight: 56, maxHeight: 120)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
HStack {
|
||||
let count = vm.replyBody.count
|
||||
Text("\(count)/2000")
|
||||
.font(.caption2)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(count > 2000 ? Color.red : Color.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let err = vm.replyError {
|
||||
Text(err).font(.caption2).foregroundStyle(.red).lineLimit(1)
|
||||
}
|
||||
|
||||
Button("Cancel") {
|
||||
vm.replyingToId = nil
|
||||
vm.replyBody = ""
|
||||
vm.replyError = nil
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button {
|
||||
Task { await vm.postReply(parentId: parentId) }
|
||||
} label: {
|
||||
if vm.isPostingReply {
|
||||
ProgressView().controlSize(.mini)
|
||||
} else {
|
||||
Text("Reply").fontWeight(.semibold).font(.caption)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
.controlSize(.mini)
|
||||
.disabled(vm.isPostingReply || vm.replyBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.replyBody.count > 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Post form
|
||||
|
||||
@ViewBuilder
|
||||
private var postForm: some View {
|
||||
if authStore.isAuthenticated {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if vm.newBody.isEmpty {
|
||||
Text("Write a comment…")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.top, 8)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
TextEditor(text: $vm.newBody)
|
||||
.font(.subheadline)
|
||||
.frame(minHeight: 72, maxHeight: 160)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
HStack {
|
||||
let count = vm.newBody.count
|
||||
Text("\(count)/2000")
|
||||
.font(.caption2)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(count > 2000 ? Color.red : Color.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let err = vm.postError {
|
||||
Text(err)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await vm.postComment() }
|
||||
} label: {
|
||||
if vm.isPosting {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Post")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
.controlSize(.small)
|
||||
.disabled(vm.isPosting || vm.newBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.newBody.count > 2000)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("Log in to leave a comment.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Loading skeleton
|
||||
|
||||
@ViewBuilder
|
||||
private var loadingPlaceholder: some View {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(0..<3, id: \.self) { _ in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 100, height: 12)
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(.systemGray6))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 12)
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(.systemGray6))
|
||||
.frame(width: 200, height: 12)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.redacted(reason: .placeholder)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CommentRow
|
||||
|
||||
private struct CommentRow: View {
|
||||
let comment: BookComment
|
||||
let myVote: String?
|
||||
let isVoting: Bool
|
||||
let isDeleting: Bool
|
||||
let isOwner: Bool
|
||||
let isLoggedIn: Bool
|
||||
let isReplyingTo: Bool
|
||||
var isReply: Bool = false
|
||||
var avatarUrl: String? = nil
|
||||
let onVote: (String) -> Void
|
||||
let onDelete: () -> Void
|
||||
let onReply: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// Avatar + Username + date
|
||||
HStack(spacing: 8) {
|
||||
avatarView
|
||||
NavigationLink(value: NavDestination.userProfile(comment.username.isEmpty ? "" : comment.username)) {
|
||||
Text(comment.username.isEmpty ? "Anonymous" : comment.username)
|
||||
.font(isReply ? .caption.weight(.medium) : .subheadline.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(comment.username.isEmpty)
|
||||
Text("·")
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(formattedDate(comment.created))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Body
|
||||
Text(comment.body)
|
||||
.font(isReply ? .caption : .subheadline)
|
||||
.foregroundStyle(.primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
// Actions
|
||||
HStack(spacing: 14) {
|
||||
// Upvote
|
||||
Button { onVote("up") } label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: myVote == "up" ? "hand.thumbsup.fill" : "hand.thumbsup")
|
||||
.font(.caption)
|
||||
Text("\(comment.upvotes)")
|
||||
.font(.caption.monospacedDigit())
|
||||
}
|
||||
.foregroundStyle(myVote == "up" ? Color.amber : .secondary)
|
||||
}
|
||||
.disabled(isVoting)
|
||||
|
||||
// Downvote
|
||||
Button { onVote("down") } label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: myVote == "down" ? "hand.thumbsdown.fill" : "hand.thumbsdown")
|
||||
.font(.caption)
|
||||
Text("\(comment.downvotes)")
|
||||
.font(.caption.monospacedDigit())
|
||||
}
|
||||
.foregroundStyle(myVote == "down" ? .red : .secondary)
|
||||
}
|
||||
.disabled(isVoting)
|
||||
|
||||
// Reply button (top-level only, logged in)
|
||||
if let onReply, isLoggedIn {
|
||||
Button { onReply() } label: {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "arrowshape.turn.up.left")
|
||||
.font(.caption)
|
||||
Text("Reply")
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundStyle(isReplyingTo ? Color.amber : .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Delete (owner only)
|
||||
if isOwner {
|
||||
Button(role: .destructive) { onDelete() } label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.caption)
|
||||
}
|
||||
.disabled(isDeleting)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.opacity(isDeleting ? 0.5 : 1)
|
||||
.animation(.easeInOut(duration: 0.15), value: isDeleting)
|
||||
}
|
||||
|
||||
private var avatarSize: CGFloat { isReply ? 20 : 24 }
|
||||
|
||||
@ViewBuilder
|
||||
private var avatarView: some View {
|
||||
if let url = avatarUrl, let imageUrl = URL(string: url) {
|
||||
AsyncImage(url: imageUrl) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image.resizable().scaledToFill()
|
||||
default:
|
||||
initialsView
|
||||
}
|
||||
}
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
initialsView
|
||||
}
|
||||
}
|
||||
|
||||
private var initialsView: some View {
|
||||
let name = comment.username.isEmpty ? "?" : comment.username
|
||||
let letters = String(name.prefix(2)).uppercased()
|
||||
return ZStack {
|
||||
Circle()
|
||||
.fill(Color(.systemGray4))
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
Text(letters)
|
||||
.font(.system(size: avatarSize * 0.42, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func formattedDate(_ iso: String) -> String {
|
||||
// PocketBase returns "2006-01-02 15:04:05.999Z" format
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = formatter.date(from: iso) {
|
||||
let rel = RelativeDateTimeFormatter()
|
||||
rel.unitsStyle = .abbreviated
|
||||
return rel.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
// Fallback: try space-separated format
|
||||
let df = DateFormatter()
|
||||
df.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ"
|
||||
if let date = df.date(from: iso) {
|
||||
let rel = RelativeDateTimeFormatter()
|
||||
rel.unitsStyle = .abbreviated
|
||||
return rel.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
return String(iso.prefix(10))
|
||||
}
|
||||
}
|
||||
@@ -29,13 +29,13 @@ struct BrowseView: View {
|
||||
// Filter chips row
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
FilterChip(label: "Sort: \(vm.sort.capitalized)", isActive: vm.sort != "popular") {
|
||||
ChipButton(label: "Sort: \(vm.sort.capitalized)", isSelected: vm.sort != "popular", style: .outlined) {
|
||||
showFilters = true
|
||||
}
|
||||
FilterChip(label: "Genre: \(vm.genre == "all" ? "All" : vm.genre.capitalized)", isActive: vm.genre != "all") {
|
||||
ChipButton(label: "Genre: \(vm.genre == "all" ? "All" : vm.genre.capitalized)", isSelected: vm.genre != "all", style: .outlined) {
|
||||
showFilters = true
|
||||
}
|
||||
FilterChip(label: "Status: \(vm.status == "all" ? "All" : vm.status.capitalized)", isActive: vm.status != "all") {
|
||||
ChipButton(label: "Status: \(vm.status == "all" ? "All" : vm.status.capitalized)", isSelected: vm.status != "all", style: .outlined) {
|
||||
showFilters = true
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@ struct BrowseView: View {
|
||||
ForEach(vm.novels) { novel in
|
||||
NavigationLink(value: NavDestination.book(novel.slug)) {
|
||||
BrowseCard(novel: novel)
|
||||
.bookCoverZoomSource(slug: novel.slug)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -90,10 +91,10 @@ struct BrowseView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Discover")
|
||||
.navigationDestination(for: NavDestination.self) { dest in
|
||||
switch dest {
|
||||
case .book(let slug): BookDetailView(slug: slug)
|
||||
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
||||
.appNavigationDestination()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
AvatarToolbarButton()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showFilters) {
|
||||
@@ -107,25 +108,6 @@ struct BrowseView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Filter chip
|
||||
|
||||
private struct FilterChip: View {
|
||||
let label: String
|
||||
let isActive: Bool
|
||||
let action: () -> Void
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(.caption.bold())
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(isActive ? Color.amber.opacity(0.15) : Color(.systemGray6), in: Capsule())
|
||||
.foregroundStyle(isActive ? .amber : .primary)
|
||||
.overlay(Capsule().strokeBorder(isActive ? Color.amber : .clear, lineWidth: 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Browse card
|
||||
|
||||
private struct BrowseCard: View {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -80,3 +80,64 @@ struct TagChip: View {
|
||||
.background(Color(.systemGray5), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Unified chip button (filter/sort chips across all screens)
|
||||
//
|
||||
// .filled → amber background when selected (genre filter chips in Library)
|
||||
// .outlined → amber border + tint when selected, grey background (sort chips, browse filter chips)
|
||||
|
||||
enum ChipButtonStyle { case filled, outlined }
|
||||
|
||||
struct ChipButton: View {
|
||||
let label: String
|
||||
let isSelected: Bool
|
||||
var style: ChipButtonStyle = .filled
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(chipFont)
|
||||
.padding(.horizontal, chipHPad)
|
||||
.padding(.vertical, 6)
|
||||
.background(background)
|
||||
.foregroundStyle(foregroundColor)
|
||||
.overlay(border)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var chipFont: Font {
|
||||
switch style {
|
||||
case .filled: return .caption.weight(isSelected ? .semibold : .regular)
|
||||
case .outlined: return .subheadline.weight(isSelected ? .semibold : .regular)
|
||||
}
|
||||
}
|
||||
|
||||
private var chipHPad: CGFloat { style == .outlined ? 14 : 12 }
|
||||
|
||||
@ViewBuilder
|
||||
private var background: some View {
|
||||
switch style {
|
||||
case .filled:
|
||||
Capsule().fill(isSelected ? Color.amber : Color(.systemGray5))
|
||||
case .outlined:
|
||||
Capsule()
|
||||
.fill(isSelected ? Color.amber.opacity(0.15) : Color(.systemGray6))
|
||||
.overlay(Capsule().stroke(isSelected ? Color.amber : .clear, lineWidth: 1.5))
|
||||
}
|
||||
}
|
||||
|
||||
private var foregroundColor: Color {
|
||||
switch style {
|
||||
case .filled: return isSelected ? .white : .primary
|
||||
case .outlined: return isSelected ? .amber : .primary
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var border: some View {
|
||||
// outlined style already has its border baked into `background`
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct HomeView: View {
|
||||
@StateObject private var vm = HomeViewModel()
|
||||
@@ -8,55 +7,93 @@ struct HomeView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 28) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
|
||||
// Stats bar
|
||||
if let stats = vm.stats {
|
||||
HStack(spacing: 0) {
|
||||
StatCell(value: "\(stats.totalBooks)", label: "Books")
|
||||
Divider().frame(height: 32)
|
||||
StatCell(value: "\(stats.totalChapters)", label: "Chapters")
|
||||
Divider().frame(height: 32)
|
||||
StatCell(value: "\(stats.booksInProgress)", label: "In Progress")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// Continue reading
|
||||
// Continue reading — all in-progress books as a horizontal shelf (Apple Books style)
|
||||
if !vm.continueReading.isEmpty {
|
||||
SectionHeader(title: "Continue Reading")
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ForEach(vm.continueReading) { item in
|
||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||
ContinueReadingCard(item: item)
|
||||
ShelfHeader(title: "Continue Reading")
|
||||
.padding(.top, 8)
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
ForEach(vm.continueReading) { item in
|
||||
NavigationLink(value: NavDestination.chapter(item.book.slug, item.chapter)) {
|
||||
ContinueReadingCard(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
ContinueReadingContextMenu(
|
||||
item: item,
|
||||
onMarkFinished: {
|
||||
Task { await markAsFinished(item.book) }
|
||||
},
|
||||
onRemove: {
|
||||
Task { await removeFromLibrary(item.book.slug) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
// Recently updated
|
||||
// Stats strip
|
||||
if let stats = vm.stats {
|
||||
StatsStrip(stats: stats)
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
// Recently updated shelf
|
||||
if !vm.recentlyUpdated.isEmpty {
|
||||
SectionHeader(title: "Recently Updated")
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 16) {
|
||||
ForEach(vm.recentlyUpdated) { book in
|
||||
NavigationLink(value: NavDestination.book(book.slug)) {
|
||||
BookCard(book: book)
|
||||
ShelfHeader(title: "Recently Updated")
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
ForEach(vm.recentlyUpdated) { book in
|
||||
NavigationLink(value: NavDestination.book(book.slug)) {
|
||||
ShelfBookCard(book: book)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
ShareLink(item: shareURL(for: book)) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
// Subscription feed shelf
|
||||
if !vm.subscriptionFeed.isEmpty {
|
||||
ShelfHeader(title: "From People You Follow")
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
ForEach(vm.subscriptionFeed) { item in
|
||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||
SubscriptionFeedCard(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
ShareLink(item: shareURL(for: item.book)) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if vm.continueReading.isEmpty && vm.recentlyUpdated.isEmpty && !vm.isLoading {
|
||||
if vm.continueReading.isEmpty && vm.recentlyUpdated.isEmpty && vm.subscriptionFeed.isEmpty && !vm.isLoading {
|
||||
EmptyStateView(
|
||||
icon: "books.vertical",
|
||||
title: "Your library is empty",
|
||||
@@ -71,78 +108,265 @@ struct HomeView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 60)
|
||||
}
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
.navigationTitle("Home")
|
||||
.navigationDestination(for: NavDestination.self) { dest in
|
||||
switch dest {
|
||||
case .book(let slug): BookDetailView(slug: slug)
|
||||
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
||||
|
||||
Color.clear.frame(height: 20)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Reading Now")
|
||||
.appNavigationDestination()
|
||||
.refreshable { await vm.load() }
|
||||
.task { await vm.load() }
|
||||
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
|
||||
Button("OK") { vm.error = nil }
|
||||
} message: {
|
||||
Text(vm.error ?? "")
|
||||
.errorAlert($vm.error)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
AvatarToolbarButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func markAsFinished(_ book: Book) async {
|
||||
do {
|
||||
try await APIClient.shared.setProgress(slug: book.slug, chapter: book.totalChapters)
|
||||
await vm.load() // Refresh home
|
||||
} catch {
|
||||
vm.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func removeFromLibrary(_ slug: String) async {
|
||||
do {
|
||||
try await APIClient.shared.deleteProgress(slug: slug)
|
||||
await vm.load() // Refresh home
|
||||
} catch {
|
||||
vm.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func shareURL(for book: Book) -> URL {
|
||||
let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
|
||||
?? "https://v2.libnovel.kalekber.cc"
|
||||
return URL(string: "\(baseURL)/books/\(book.slug)")!
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shelf header
|
||||
|
||||
private struct ShelfHeader: View {
|
||||
let title: String
|
||||
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(.title3.bold())
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Horizontal shelf: continue reading card (Apple Books style)
|
||||
|
||||
private struct ContinueReadingCard: View {
|
||||
let item: ContinueReadingItem
|
||||
|
||||
private var progressFraction: Double {
|
||||
guard item.book.totalChapters > 0 else { return 0 }
|
||||
return min(1.0, Double(item.chapter) / Double(item.book.totalChapters))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Cover
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
AsyncCoverImage(url: item.book.cover)
|
||||
.frame(width: 130, height: 188)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.shadow(color: .black.opacity(0.18), radius: 6, y: 3)
|
||||
.bookCoverZoomSource(slug: item.book.slug)
|
||||
|
||||
// "Continue" pill badge at bottom-left
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
Text("Ch.\(item.chapter)")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
}
|
||||
.foregroundStyle(.black.opacity(0.85))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 5)
|
||||
.background(Capsule().fill(Color.amber))
|
||||
.padding(8)
|
||||
}
|
||||
|
||||
// Title
|
||||
Text(item.book.title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(2)
|
||||
.frame(width: 130, alignment: .leading)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
// Progress bar — show at least a 4pt sliver so early chapters aren't invisible
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Color.secondary.opacity(0.2))
|
||||
Capsule()
|
||||
.fill(Color.amber.opacity(0.85))
|
||||
.frame(width: max(4, geo.size.width * progressFraction))
|
||||
}
|
||||
}
|
||||
.frame(width: 130, height: 3)
|
||||
|
||||
// Percent label — floor at 1% so early chapters don't display "0%"
|
||||
Text("\(max(1, Int(progressFraction * 100)))% complete")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(width: 130)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Horizontal shelf: recently updated book card
|
||||
|
||||
private struct ShelfBookCard: View {
|
||||
let book: Book
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AsyncCoverImage(url: book.cover)
|
||||
.frame(width: 110, height: 158)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
|
||||
.bookCoverZoomSource(slug: book.slug)
|
||||
|
||||
Text(book.title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(2)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
|
||||
Text(book.author)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting components
|
||||
// MARK: - Horizontal shelf: subscription feed card
|
||||
|
||||
private struct StatCell: View {
|
||||
private struct SubscriptionFeedCard: View {
|
||||
let item: SubscriptionFeedItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AsyncCoverImage(url: item.book.cover)
|
||||
.frame(width: 110, height: 158)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
|
||||
.bookCoverZoomSource(slug: item.book.slug)
|
||||
|
||||
Text(item.book.title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(2)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
|
||||
// Tappable "via @username" attribution
|
||||
NavigationLink(value: NavDestination.userProfile(item.readerUsername)) {
|
||||
Text("via @\(item.readerUsername)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color.amber)
|
||||
.lineLimit(1)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stats strip (compact inline)
|
||||
|
||||
private struct StatsStrip: View {
|
||||
let stats: HomeStats
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
StatPill(icon: "books.vertical.fill", value: "\(stats.totalBooks)", label: "Books")
|
||||
Divider().frame(height: 28)
|
||||
StatPill(icon: "text.alignleft", value: "\(stats.totalChapters)", label: "Chapters")
|
||||
Divider().frame(height: 28)
|
||||
StatPill(icon: "bookmark.fill", value: "\(stats.booksInProgress)", label: "In Progress")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatPill: View {
|
||||
let icon: String
|
||||
let value: String
|
||||
let label: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 2) {
|
||||
Text(value).font(.title2.bold()).foregroundStyle(.primary)
|
||||
Text(label).font(.caption).foregroundStyle(.secondary)
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(Color.amber.opacity(0.8))
|
||||
Text(value)
|
||||
.font(.subheadline.bold().monospacedDigit())
|
||||
.foregroundStyle(.primary)
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SectionHeader: View {
|
||||
let title: String
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(.title3.bold())
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
// MARK: - Context menus
|
||||
|
||||
private struct ContinueReadingCard: View {
|
||||
private struct ContinueReadingContextMenu: View {
|
||||
let item: ContinueReadingItem
|
||||
let onMarkFinished: () -> Void
|
||||
let onRemove: () -> Void
|
||||
|
||||
private var isFinished: Bool {
|
||||
guard item.book.totalChapters > 0 else { return false }
|
||||
return item.chapter >= item.book.totalChapters
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
KFImage(URL(string: item.book.cover))
|
||||
.resizable()
|
||||
.placeholder { coverPlaceholder }
|
||||
.scaledToFill()
|
||||
.frame(width: 120, height: 170)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
Text("Ch.\(item.chapter)")
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.padding(6)
|
||||
Group {
|
||||
// Share book
|
||||
ShareLink(item: shareURL) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Mark as finished (only show if not already finished)
|
||||
if !isFinished {
|
||||
Button {
|
||||
onMarkFinished()
|
||||
} label: {
|
||||
Label("Mark as Finished", systemImage: "checkmark.circle")
|
||||
}
|
||||
Text(item.book.title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(2)
|
||||
.frame(width: 120, alignment: .leading)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Remove from library (destructive)
|
||||
Button(role: .destructive) {
|
||||
onRemove()
|
||||
} label: {
|
||||
Label("Remove from Library", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
private var coverPlaceholder: some View {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 120, height: 170)
|
||||
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
|
||||
|
||||
private var shareURL: URL {
|
||||
let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
|
||||
?? "https://v2.libnovel.kalekber.cc"
|
||||
return URL(string: "\(baseURL)/books/\(item.book.slug)")!
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,83 @@ import Kingfisher
|
||||
|
||||
struct LibraryView: View {
|
||||
@StateObject private var vm = LibraryViewModel()
|
||||
@State private var sortOrder: SortOrder = .recentlyRead
|
||||
@State private var readingFilter: ReadingFilter = .all
|
||||
@State private var selectedGenre: String = "all"
|
||||
@State private var searchText = ""
|
||||
|
||||
enum SortOrder: String, CaseIterable {
|
||||
case recentlyRead = "Recent"
|
||||
case title = "Title"
|
||||
case author = "Author"
|
||||
case progress = "Progress"
|
||||
}
|
||||
|
||||
enum ReadingFilter: String, CaseIterable {
|
||||
case all = "All"
|
||||
case inProgress = "In Progress"
|
||||
case completed = "Completed"
|
||||
}
|
||||
|
||||
// All distinct genres across the library, sorted alphabetically.
|
||||
private var availableGenres: [String] {
|
||||
let all = vm.items.flatMap { $0.book.genres }
|
||||
let unique = Array(Set(all)).sorted()
|
||||
return unique
|
||||
}
|
||||
|
||||
private var filtered: [LibraryItem] {
|
||||
var result = vm.items
|
||||
|
||||
// 1. Reading filter
|
||||
switch readingFilter {
|
||||
case .all:
|
||||
break
|
||||
case .inProgress:
|
||||
result = result.filter { !isCompleted($0) }
|
||||
case .completed:
|
||||
result = result.filter { isCompleted($0) }
|
||||
}
|
||||
|
||||
// 2. Genre filter
|
||||
if selectedGenre != "all" {
|
||||
result = result.filter { $0.book.genres.contains(selectedGenre) }
|
||||
}
|
||||
|
||||
// 3. Sort
|
||||
switch sortOrder {
|
||||
case .recentlyRead:
|
||||
break // server returns by recency
|
||||
case .title:
|
||||
result = result.sorted { $0.book.title < $1.book.title }
|
||||
case .author:
|
||||
result = result.sorted { $0.book.author < $1.book.author }
|
||||
case .progress:
|
||||
result = result.sorted { ($0.lastChapter ?? 0) > ($1.lastChapter ?? 0) }
|
||||
}
|
||||
|
||||
// 4. Search
|
||||
if !searchText.isEmpty {
|
||||
result = result.filter {
|
||||
$0.book.title.localizedCaseInsensitiveContains(searchText) ||
|
||||
$0.book.author.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private func isCompleted(_ item: LibraryItem) -> Bool {
|
||||
// Treat as completed if book status is "completed" OR
|
||||
// the user has read up to (or past) the total chapter count.
|
||||
if item.book.status.lowercased() == "completed",
|
||||
let ch = item.lastChapter,
|
||||
item.book.totalChapters > 0,
|
||||
ch >= item.book.totalChapters {
|
||||
return true
|
||||
}
|
||||
return item.book.status.lowercased() == "completed" && (item.lastChapter ?? 0) > 0
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -18,69 +95,310 @@ struct LibraryView: View {
|
||||
)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(
|
||||
columns: [GridItem(.adaptive(minimum: 150), spacing: 12)],
|
||||
spacing: 16
|
||||
) {
|
||||
ForEach(vm.items) { item in
|
||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||
LibraryCard(item: item)
|
||||
VStack(spacing: 0) {
|
||||
// Search bar
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Search library", text: $searchText)
|
||||
.font(.subheadline)
|
||||
if !searchText.isEmpty {
|
||||
Button { searchText = "" } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
|
||||
// Reading filter (All / In Progress / Completed)
|
||||
Picker("", selection: $readingFilter) {
|
||||
ForEach(ReadingFilter.allCases, id: \.self) { f in
|
||||
Text(f.rawValue).tag(f)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 12)
|
||||
|
||||
// Genre filter chips (only shown when genres are available)
|
||||
if !availableGenres.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
// "All" chip
|
||||
ChipButton(
|
||||
label: "All",
|
||||
isSelected: selectedGenre == "all",
|
||||
style: .filled
|
||||
) {
|
||||
withAnimation { selectedGenre = "all" }
|
||||
}
|
||||
ForEach(availableGenres, id: \.self) { genre in
|
||||
ChipButton(
|
||||
label: genre.capitalized,
|
||||
isSelected: selectedGenre == genre,
|
||||
style: .filled
|
||||
) {
|
||||
withAnimation {
|
||||
selectedGenre = selectedGenre == genre ? "all" : genre
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
}
|
||||
|
||||
// Sort chips
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(SortOrder.allCases, id: \.self) { order in
|
||||
ChipButton(
|
||||
label: order.rawValue,
|
||||
isSelected: sortOrder == order,
|
||||
style: .outlined
|
||||
) {
|
||||
withAnimation { sortOrder = order }
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
|
||||
// Book count
|
||||
Text("\(filtered.count) book\(filtered.count == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
if filtered.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: readingFilter == .completed ? "checkmark.circle" : "book")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(emptyMessage)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 60)
|
||||
} else {
|
||||
// 2-column grid (matches Discover)
|
||||
LazyVGrid(
|
||||
columns: [
|
||||
GridItem(.flexible(), spacing: 12),
|
||||
GridItem(.flexible(), spacing: 12)
|
||||
],
|
||||
spacing: 16
|
||||
) {
|
||||
ForEach(filtered) { item in
|
||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||
LibraryBookCard(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
BookContextMenu(
|
||||
book: item.book,
|
||||
isFinished: isCompleted(item),
|
||||
onMarkFinished: {
|
||||
Task {
|
||||
await markAsFinished(item.book)
|
||||
}
|
||||
},
|
||||
onRemove: {
|
||||
Task {
|
||||
await removeFromLibrary(item.book.slug)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Library")
|
||||
.navigationDestination(for: NavDestination.self) { dest in
|
||||
switch dest {
|
||||
case .book(let slug): BookDetailView(slug: slug)
|
||||
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
||||
}
|
||||
}
|
||||
.appNavigationDestination()
|
||||
.refreshable { await vm.load() }
|
||||
.task { await vm.load() }
|
||||
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
|
||||
Button("OK") { vm.error = nil }
|
||||
} message: { Text(vm.error ?? "") }
|
||||
.errorAlert($vm.error)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
AvatarToolbarButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyMessage: String {
|
||||
switch readingFilter {
|
||||
case .all:
|
||||
return selectedGenre == "all" ? "No books match your search." : "No \(selectedGenre.capitalized) books in your library."
|
||||
case .inProgress:
|
||||
return "No books in progress."
|
||||
case .completed:
|
||||
return "No completed books yet."
|
||||
}
|
||||
}
|
||||
|
||||
private func markAsFinished(_ book: Book) async {
|
||||
do {
|
||||
try await APIClient.shared.setProgress(slug: book.slug, chapter: book.totalChapters)
|
||||
await vm.load() // Refresh library
|
||||
} catch {
|
||||
vm.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func removeFromLibrary(_ slug: String) async {
|
||||
do {
|
||||
try await APIClient.shared.deleteProgress(slug: slug)
|
||||
await vm.load() // Refresh library
|
||||
} catch {
|
||||
vm.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LibraryCard: View {
|
||||
// MARK: - Library book card (3-column)
|
||||
|
||||
private struct LibraryBookCard: View {
|
||||
let item: LibraryItem
|
||||
|
||||
private var progressFraction: Double {
|
||||
guard let ch = item.lastChapter, item.book.totalChapters > 0 else { return 0 }
|
||||
return Double(ch) / Double(item.book.totalChapters)
|
||||
}
|
||||
|
||||
private var isCompleted: Bool {
|
||||
progressFraction >= 1.0
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
// Cover image
|
||||
KFImage(URL(string: item.book.cover))
|
||||
.resizable()
|
||||
.placeholder {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color(.systemGray5))
|
||||
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
|
||||
.overlay(
|
||||
Image(systemName: "book.closed")
|
||||
.foregroundStyle(.secondary)
|
||||
)
|
||||
}
|
||||
.scaledToFill()
|
||||
.frame(height: 200)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.frame(maxWidth: .infinity)
|
||||
.aspectRatio(2/3, contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.shadow(color: .black.opacity(0.14), radius: 4, y: 2)
|
||||
.bookCoverZoomSource(slug: item.book.slug)
|
||||
|
||||
if let ch = item.lastChapter {
|
||||
Text("Ch.\(ch)")
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.padding(6)
|
||||
// Progress arc or completed checkmark in top-right corner
|
||||
if isCompleted {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.background(Circle().fill(Color.amber).padding(1))
|
||||
.padding(5)
|
||||
} else if progressFraction > 0 {
|
||||
ProgressArc(fraction: progressFraction)
|
||||
.frame(width: 28, height: 28)
|
||||
.padding(4)
|
||||
}
|
||||
}
|
||||
|
||||
// Title
|
||||
Text(item.book.title)
|
||||
.font(.caption.bold())
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(2)
|
||||
Text(item.book.author)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
// Chapter badge if present
|
||||
if let ch = item.lastChapter {
|
||||
Text(isCompleted ? "Finished" : "Ch.\(ch)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(isCompleted ? Color.amber : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Circular progress arc overlay
|
||||
|
||||
private struct ProgressArc: View {
|
||||
let fraction: Double // 0...1
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(.ultraThinMaterial)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: fraction)
|
||||
.stroke(Color.amber, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 0.5), value: fraction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Book context menu
|
||||
|
||||
private struct BookContextMenu: View {
|
||||
let book: Book
|
||||
let isFinished: Bool
|
||||
let onMarkFinished: () -> Void
|
||||
let onRemove: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
// Share book
|
||||
ShareLink(item: shareURL) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Mark as finished (only show if not already finished)
|
||||
if !isFinished {
|
||||
Button {
|
||||
onMarkFinished()
|
||||
} label: {
|
||||
Label("Mark as Finished", systemImage: "checkmark.circle")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Remove from library (destructive)
|
||||
Button(role: .destructive) {
|
||||
onRemove()
|
||||
} label: {
|
||||
Label("Remove from Library", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var shareURL: URL {
|
||||
// Share the book detail page URL
|
||||
let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
|
||||
?? "https://v2.libnovel.kalekber.cc"
|
||||
return URL(string: "\(baseURL)/books/\(book.slug)")!
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
341
ios/LibNovel/LibNovel/Views/Profile/AccountMenuSheet.swift
Normal file
341
ios/LibNovel/LibNovel/Views/Profile/AccountMenuSheet.swift
Normal file
@@ -0,0 +1,341 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import Kingfisher
|
||||
|
||||
// MARK: - AvatarNavButton
|
||||
// Drop this into any NavigationStack toolbar to get an avatar button that opens the account sheet.
|
||||
//
|
||||
// Usage:
|
||||
// .toolbar { AvatarToolbarButton() }
|
||||
|
||||
struct AvatarToolbarButton: View {
|
||||
@EnvironmentObject private var authStore: AuthStore
|
||||
@State private var showAccount = false
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
showAccount = true
|
||||
} label: {
|
||||
AvatarThumb(urlString: authStore.user?.avatarURL, size: 30)
|
||||
}
|
||||
.sheet(isPresented: $showAccount) {
|
||||
AccountMenuSheet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AvatarThumb
|
||||
// Reusable small circular avatar (used by both toolbar button and the sheet header).
|
||||
|
||||
struct AvatarThumb: View {
|
||||
let urlString: String?
|
||||
let size: CGFloat
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let str = urlString, let url = URL(string: str) {
|
||||
KFImage(url)
|
||||
.placeholder { placeholderCircle }
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
placeholderCircle
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(Circle())
|
||||
.overlay(Circle().stroke(Color.amber.opacity(0.6), lineWidth: 1.5))
|
||||
}
|
||||
|
||||
private var placeholderCircle: some View {
|
||||
Circle()
|
||||
.fill(Color(.systemGray4))
|
||||
.overlay(
|
||||
Image(systemName: "person.fill")
|
||||
.font(.system(size: size * 0.5))
|
||||
.foregroundStyle(Color.amber)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AccountMenuSheet
|
||||
|
||||
struct AccountMenuSheet: View {
|
||||
@EnvironmentObject private var authStore: AuthStore
|
||||
@StateObject private var vm = ProfileViewModel()
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var showChangePassword = false
|
||||
|
||||
// Avatar upload
|
||||
@State private var photoPickerItem: PhotosPickerItem?
|
||||
@State private var pendingCropImage: UIImage?
|
||||
@State private var avatarURL: String? = nil
|
||||
@State private var avatarUploading = false
|
||||
@State private var avatarError: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
// ── User header ────────────────────────────────────────────
|
||||
Section {
|
||||
HStack(spacing: 16) {
|
||||
avatarPicker
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(authStore.user?.username ?? "")
|
||||
.font(.headline)
|
||||
Text(authStore.user?.role.capitalized ?? "")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let err = avatarError {
|
||||
Text(err)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
// ── Reading settings ───────────────────────────────────────
|
||||
Section("Reading Settings") {
|
||||
voicePicker
|
||||
speedSlider
|
||||
Toggle("Auto-advance chapter", isOn: Binding(
|
||||
get: { authStore.settings.autoNext },
|
||||
set: { newVal in
|
||||
Task {
|
||||
var s = authStore.settings
|
||||
s.autoNext = newVal
|
||||
await authStore.saveSettings(s)
|
||||
}
|
||||
}
|
||||
))
|
||||
.tint(.amber)
|
||||
}
|
||||
|
||||
// ── Sessions ───────────────────────────────────────────────
|
||||
Section("Active Sessions") {
|
||||
if vm.sessionsLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
ForEach(vm.sessions) { session in
|
||||
SessionRow(session: session) {
|
||||
Task { await vm.revokeSession(id: session.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Account ────────────────────────────────────────────────
|
||||
Section("Account") {
|
||||
Button("Change Password") { showChangePassword = true }
|
||||
Button("Sign Out", role: .destructive) {
|
||||
dismiss()
|
||||
Task { await authStore.logout() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Account")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.task { await vm.loadSessions() }
|
||||
.sheet(isPresented: $showChangePassword) {
|
||||
ChangePasswordView()
|
||||
}
|
||||
.sheet(item: Binding(
|
||||
get: { pendingCropImage.map { CropImageItem(image: $0) } },
|
||||
set: { if $0 == nil { pendingCropImage = nil } }
|
||||
)) { item in
|
||||
AvatarCropView(image: item.image) { croppedData in
|
||||
pendingCropImage = nil
|
||||
Task { await uploadCroppedData(croppedData) }
|
||||
} onCancel: {
|
||||
pendingCropImage = nil
|
||||
}
|
||||
}
|
||||
.errorAlert($vm.error)
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
// MARK: - Avatar upload
|
||||
|
||||
private func loadImageForCrop(_ item: PhotosPickerItem) async {
|
||||
guard let data = try? await item.loadTransferable(type: Data.self),
|
||||
let image = UIImage(data: data) else {
|
||||
avatarError = "Could not read image"
|
||||
return
|
||||
}
|
||||
pendingCropImage = image
|
||||
}
|
||||
|
||||
private func uploadCroppedData(_ data: Data) async {
|
||||
avatarUploading = true
|
||||
avatarError = nil
|
||||
defer { avatarUploading = false }
|
||||
do {
|
||||
let url = try await APIClient.shared.uploadAvatar(data, mimeType: "image/jpeg")
|
||||
avatarURL = url
|
||||
await authStore.validateToken()
|
||||
} catch {
|
||||
avatarError = "Upload failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Avatar picker
|
||||
|
||||
@ViewBuilder
|
||||
private var avatarPicker: some View {
|
||||
PhotosPicker(selection: $photoPickerItem,
|
||||
matching: .images,
|
||||
photoLibrary: .shared()) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 72, height: 72)
|
||||
|
||||
if avatarUploading {
|
||||
ProgressView()
|
||||
.frame(width: 72, height: 72)
|
||||
} else if let urlStr = avatarURL ?? authStore.user?.avatarURL,
|
||||
let url = URL(string: urlStr) {
|
||||
KFImage(url)
|
||||
.placeholder {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(.amber)
|
||||
.frame(width: 72, height: 72)
|
||||
}
|
||||
|
||||
// Camera badge
|
||||
if !avatarUploading {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
ZStack {
|
||||
Circle().fill(Color.amber).frame(width: 22, height: 22)
|
||||
Image(systemName: "camera.fill")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(.black)
|
||||
}
|
||||
.offset(x: 2, y: 2)
|
||||
}
|
||||
}
|
||||
.frame(width: 72, height: 72)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onChange(of: photoPickerItem) { _, item in
|
||||
guard let item else { return }
|
||||
Task { await loadImageForCrop(item) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Voice picker
|
||||
|
||||
@ViewBuilder
|
||||
private var voicePicker: some View {
|
||||
Picker("TTS Voice", selection: Binding(
|
||||
get: { authStore.settings.voice },
|
||||
set: { newVoice in
|
||||
Task {
|
||||
var s = authStore.settings
|
||||
s.voice = newVoice
|
||||
await authStore.saveSettings(s)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
if vm.voices.isEmpty {
|
||||
Text("Default").tag("af_bella")
|
||||
} else {
|
||||
ForEach(vm.voices, id: \.self) { v in
|
||||
Text(v).tag(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await vm.loadVoices() }
|
||||
}
|
||||
|
||||
// MARK: - Speed slider
|
||||
|
||||
@ViewBuilder
|
||||
private var speedSlider: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text("Playback Speed")
|
||||
Spacer()
|
||||
Text("\(authStore.settings.speed, specifier: "%.1f")×")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { authStore.settings.speed },
|
||||
set: { newSpeed in
|
||||
Task {
|
||||
var s = authStore.settings
|
||||
s.speed = newSpeed
|
||||
await authStore.saveSettings(s)
|
||||
}
|
||||
}
|
||||
),
|
||||
in: 0.5...2.0, step: 0.25
|
||||
)
|
||||
.tint(.amber)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session row (local copy — mirrors ProfileView.SessionRow)
|
||||
|
||||
private struct SessionRow: View {
|
||||
let session: UserSession
|
||||
let onRevoke: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Image(systemName: "iphone")
|
||||
Text(session.userAgent.isEmpty ? "Unknown device" : session.userAgent)
|
||||
.font(.subheadline)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if session.isCurrent {
|
||||
Text("This device")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.amber)
|
||||
} else {
|
||||
Button("Revoke", role: .destructive, action: onRevoke)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
Text("Last seen: \(session.lastSeen.prefix(10))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CropImageItem (Identifiable wrapper for the sheet)
|
||||
|
||||
private struct CropImageItem: Identifiable {
|
||||
let id = UUID()
|
||||
let image: UIImage
|
||||
}
|
||||
270
ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift
Normal file
270
ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift
Normal file
@@ -0,0 +1,270 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - AvatarCropView
|
||||
// A sheet that lets the user pan and pinch a photo to fill a 1:1 circular crop region.
|
||||
// Call: .sheet(item: $cropImage) { AvatarCropView(image: $0.image, onConfirm: { croppedData in … }) }
|
||||
|
||||
struct AvatarCropView: View {
|
||||
let image: UIImage
|
||||
let onConfirm: (Data) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
// Crop circle diameter (points)
|
||||
private let cropSize: CGFloat = 280
|
||||
|
||||
// Pan/zoom state — all in screen points, relative to the image's natural fill-fitted frame
|
||||
@State private var scale: CGFloat = 1.0
|
||||
@State private var lastScale: CGFloat = 1.0
|
||||
@State private var offset: CGSize = .zero
|
||||
@State private var lastOffset: CGSize = .zero
|
||||
|
||||
// Container size captured from GeometryReader
|
||||
@State private var containerSize: CGSize = .zero
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
GeometryReader { geo in
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
// Draggable / pinchable image
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
.scaleEffect(scale, anchor: .center)
|
||||
.offset(offset)
|
||||
.gesture(
|
||||
SimultaneousGesture(
|
||||
MagnificationGesture()
|
||||
.onChanged { value in
|
||||
let proposed = lastScale * value
|
||||
scale = max(minScale(in: geo.size), proposed)
|
||||
}
|
||||
.onEnded { _ in
|
||||
lastScale = scale
|
||||
clampOffset(in: geo.size)
|
||||
lastOffset = offset
|
||||
},
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
let proposed = CGSize(
|
||||
width: lastOffset.width + value.translation.width,
|
||||
height: lastOffset.height + value.translation.height
|
||||
)
|
||||
offset = clampedOffset(proposed, in: geo.size)
|
||||
}
|
||||
.onEnded { _ in
|
||||
lastOffset = offset
|
||||
}
|
||||
)
|
||||
)
|
||||
.clipped()
|
||||
|
||||
// Dim overlay with transparent crop circle cut out
|
||||
CropOverlay(cropSize: cropSize, containerSize: geo.size)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.onAppear {
|
||||
containerSize = geo.size
|
||||
fitImageInitially(in: geo.size)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Crop Photo")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel", action: onCancel)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Use Photo") {
|
||||
confirmCrop()
|
||||
}
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
}
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Initial fit
|
||||
|
||||
private func fitImageInitially(in size: CGSize) {
|
||||
// The image is displayed with .scaledToFill() in the container (size).
|
||||
// That means one dimension equals the container and the other overflows.
|
||||
// We want the image to be just large enough that the crop circle is fully
|
||||
// covered — i.e. the fill-fitted image's shorter displayed dimension >= cropSize.
|
||||
//
|
||||
// .scaledToFill fills the container, so the image already covers the container.
|
||||
// The minimum scale that covers the crop square is therefore 1.0 (image already
|
||||
// fills container which is >= cropSize on both axes).
|
||||
// We keep scale = 1.0 and centre the offset.
|
||||
scale = 1.0
|
||||
lastScale = 1.0
|
||||
offset = .zero
|
||||
lastOffset = .zero
|
||||
}
|
||||
|
||||
// MARK: - Clamping helpers
|
||||
|
||||
/// Minimum scale: the image (at .scaledToFill in container) must cover the crop square.
|
||||
/// At scale=1 the image already fills the container; cropSize <= container dimension,
|
||||
/// so 1.0 is always sufficient. We cap at 1.0 to prevent zooming out below fill.
|
||||
private func minScale(in containerSize: CGSize) -> CGFloat {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
/// The displayed (fill-fitted) image size in the container at the given user scale.
|
||||
private func displayedImageSize(in containerSize: CGSize, userScale: CGFloat) -> CGSize {
|
||||
let imgAspect = image.size.width / image.size.height
|
||||
let containerAspect = containerSize.width / containerSize.height
|
||||
|
||||
// .scaledToFill base size before user scale
|
||||
let baseWidth: CGFloat
|
||||
let baseHeight: CGFloat
|
||||
if imgAspect > containerAspect {
|
||||
// image is wider — height fills container
|
||||
baseHeight = containerSize.height
|
||||
baseWidth = baseHeight * imgAspect
|
||||
} else {
|
||||
// image is taller — width fills container
|
||||
baseWidth = containerSize.width
|
||||
baseHeight = baseWidth / imgAspect
|
||||
}
|
||||
return CGSize(width: baseWidth * userScale, height: baseHeight * userScale)
|
||||
}
|
||||
|
||||
/// Maximum offset so the crop square is always covered by the image.
|
||||
private func clampedOffset(_ proposed: CGSize, in containerSize: CGSize) -> CGSize {
|
||||
let displayed = displayedImageSize(in: containerSize, userScale: scale)
|
||||
// Half of how much the image overflows the container on each axis
|
||||
let maxX = max(0, (displayed.width - cropSize) / 2)
|
||||
let maxY = max(0, (displayed.height - cropSize) / 2)
|
||||
return CGSize(
|
||||
width: min(maxX, max(-maxX, proposed.width)),
|
||||
height: min(maxY, max(-maxY, proposed.height))
|
||||
)
|
||||
}
|
||||
|
||||
private func clampOffset(in containerSize: CGSize) {
|
||||
offset = clampedOffset(offset, in: containerSize)
|
||||
}
|
||||
|
||||
// MARK: - Crop
|
||||
|
||||
private func confirmCrop() {
|
||||
let size = containerSize.width > 0 ? containerSize : CGSize(width: 390, height: 844)
|
||||
let outputSize = CGSize(width: 400, height: 400)
|
||||
|
||||
// --- Step 1: compute the fill-fitted base display size ---
|
||||
let imgAspect = image.size.width / image.size.height
|
||||
let containerAspect = size.width / size.height
|
||||
|
||||
let baseDisplayW: CGFloat
|
||||
let baseDisplayH: CGFloat
|
||||
if imgAspect > containerAspect {
|
||||
baseDisplayH = size.height
|
||||
baseDisplayW = baseDisplayH * imgAspect
|
||||
} else {
|
||||
baseDisplayW = size.width
|
||||
baseDisplayH = baseDisplayW / imgAspect
|
||||
}
|
||||
|
||||
// Displayed image size after user zoom
|
||||
let displayW = baseDisplayW * scale
|
||||
let displayH = baseDisplayH * scale
|
||||
|
||||
// --- Step 2: the crop square centre is the container centre ---
|
||||
// The image centre (after offset) in container coords:
|
||||
let imageCentreX = size.width / 2 + offset.width
|
||||
let imageCentreY = size.height / 2 + offset.height
|
||||
|
||||
// Top-left of the crop square in container coords:
|
||||
let cropOriginX = (size.width - cropSize) / 2
|
||||
let cropOriginY = (size.height - cropSize) / 2
|
||||
|
||||
// Top-left of the crop square relative to the image's top-left in display space:
|
||||
let imageOriginX = imageCentreX - displayW / 2
|
||||
let imageOriginY = imageCentreY - displayH / 2
|
||||
|
||||
let cropInImageX = cropOriginX - imageOriginX // pixels in display space
|
||||
let cropInImageY = cropOriginY - imageOriginY
|
||||
|
||||
// --- Step 3: convert display-space coords to image pixel coords ---
|
||||
let displayToPixelX = image.size.width / displayW
|
||||
let displayToPixelY = image.size.height / displayH
|
||||
|
||||
let pixelX = cropInImageX * displayToPixelX
|
||||
let pixelY = cropInImageY * displayToPixelY
|
||||
let pixelW = cropSize * displayToPixelX
|
||||
let pixelH = cropSize * displayToPixelY
|
||||
|
||||
let cropRect = CGRect(x: pixelX, y: pixelY, width: pixelW, height: pixelH)
|
||||
.intersection(CGRect(origin: .zero, size: image.size))
|
||||
|
||||
guard cropRect.width > 0, cropRect.height > 0 else {
|
||||
// Fallback: use entire image
|
||||
if let jpeg = image.jpegData(compressionQuality: 0.9) { onConfirm(jpeg) }
|
||||
return
|
||||
}
|
||||
|
||||
// --- Step 4: render cropped region into 400×400 ---
|
||||
let renderer = UIGraphicsImageRenderer(size: outputSize)
|
||||
let cropped = renderer.image { _ in
|
||||
// Draw only the cropRect portion of the image scaled to fill outputSize
|
||||
let destRect = CGRect(origin: .zero, size: outputSize)
|
||||
// UIImage.draw(in:) draws the full image; we use CGImage cropping instead
|
||||
if let cgImg = image.cgImage?.cropping(to: cropRect) {
|
||||
let croppedUI = UIImage(cgImage: cgImg, scale: image.scale, orientation: image.imageOrientation)
|
||||
croppedUI.draw(in: destRect)
|
||||
} else {
|
||||
image.draw(in: destRect)
|
||||
}
|
||||
}
|
||||
|
||||
if let jpeg = cropped.jpegData(compressionQuality: 0.9) {
|
||||
onConfirm(jpeg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Crop overlay
|
||||
|
||||
private struct CropOverlay: View {
|
||||
let cropSize: CGFloat
|
||||
let containerSize: CGSize
|
||||
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
// Fill entire canvas with semi-transparent black
|
||||
context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(.black.opacity(0.55)))
|
||||
// Cut out the crop circle in the centre
|
||||
let origin = CGPoint(
|
||||
x: (size.width - cropSize) / 2,
|
||||
y: (size.height - cropSize) / 2
|
||||
)
|
||||
let cropRect = CGRect(origin: origin, size: CGSize(width: cropSize, height: cropSize))
|
||||
context.blendMode = .destinationOut
|
||||
context.fill(Path(ellipseIn: cropRect), with: .color(.white))
|
||||
}
|
||||
.compositingGroup()
|
||||
.overlay {
|
||||
// Amber circle border around the crop region
|
||||
let origin = CGPoint(
|
||||
x: (containerSize.width - cropSize) / 2,
|
||||
y: (containerSize.height - cropSize) / 2
|
||||
)
|
||||
Circle()
|
||||
.stroke(Color.amber.opacity(0.8), lineWidth: 2)
|
||||
.frame(width: cropSize, height: cropSize)
|
||||
.position(
|
||||
x: origin.x + cropSize / 2,
|
||||
y: origin.y + cropSize / 2
|
||||
)
|
||||
}
|
||||
.frame(width: containerSize.width, height: containerSize.height)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,43 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import Kingfisher
|
||||
|
||||
struct ProfileView: View {
|
||||
@EnvironmentObject var authStore: AuthStore
|
||||
@StateObject private var vm = ProfileViewModel()
|
||||
@State private var showChangePassword = false
|
||||
|
||||
// Avatar upload state
|
||||
@State private var photoPickerItem: PhotosPickerItem?
|
||||
@State private var pendingCropImage: UIImage? // image waiting to be cropped
|
||||
@State private var avatarURL: String? = nil
|
||||
@State private var avatarUploading = false
|
||||
@State private var avatarError: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
// User header
|
||||
// ── User header ────────────────────────────────────────────
|
||||
Section {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.amber)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 16) {
|
||||
avatarPicker
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(authStore.user?.username ?? "")
|
||||
.font(.headline)
|
||||
Text(authStore.user?.role.capitalized ?? "")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let err = avatarError {
|
||||
Text(err)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
// Reading settings
|
||||
// ── Reading settings ───────────────────────────────────────
|
||||
Section("Reading Settings") {
|
||||
voicePicker
|
||||
speedSlider
|
||||
@@ -42,7 +54,7 @@ struct ProfileView: View {
|
||||
.tint(.amber)
|
||||
}
|
||||
|
||||
// Sessions
|
||||
// ── Sessions ───────────────────────────────────────────────
|
||||
Section("Active Sessions") {
|
||||
if vm.sessionsLoading {
|
||||
ProgressView()
|
||||
@@ -55,7 +67,7 @@ struct ProfileView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Account
|
||||
// ── Account ────────────────────────────────────────────────
|
||||
Section("Account") {
|
||||
Button("Change Password") { showChangePassword = true }
|
||||
Button("Sign Out", role: .destructive) {
|
||||
@@ -64,13 +76,114 @@ struct ProfileView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Profile")
|
||||
.task { await vm.loadSessions() }
|
||||
.task {
|
||||
await vm.loadSessions()
|
||||
}
|
||||
|
||||
.sheet(isPresented: $showChangePassword) {
|
||||
ChangePasswordView()
|
||||
}
|
||||
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
|
||||
Button("OK") { vm.error = nil }
|
||||
} message: { Text(vm.error ?? "") }
|
||||
.sheet(item: Binding(
|
||||
get: { pendingCropImage.map { CropImageItem(image: $0) } },
|
||||
set: { if $0 == nil { pendingCropImage = nil } }
|
||||
)) { item in
|
||||
AvatarCropView(image: item.image) { croppedData in
|
||||
pendingCropImage = nil
|
||||
Task { await uploadCroppedData(croppedData) }
|
||||
} onCancel: {
|
||||
pendingCropImage = nil
|
||||
}
|
||||
}
|
||||
.errorAlert($vm.error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Avatar upload
|
||||
|
||||
/// Step 1: Load the raw image from the picker and show the crop sheet.
|
||||
private func loadImageForCrop(_ item: PhotosPickerItem) async {
|
||||
guard let data = try? await item.loadTransferable(type: Data.self),
|
||||
let image = UIImage(data: data) else {
|
||||
avatarError = "Could not read image"
|
||||
return
|
||||
}
|
||||
pendingCropImage = image
|
||||
}
|
||||
|
||||
/// Step 2: Called by AvatarCropView once the user confirms. Upload the cropped JPEG.
|
||||
private func uploadCroppedData(_ data: Data) async {
|
||||
avatarUploading = true
|
||||
avatarError = nil
|
||||
defer { avatarUploading = false }
|
||||
do {
|
||||
let url = try await APIClient.shared.uploadAvatar(data, mimeType: "image/jpeg")
|
||||
avatarURL = url
|
||||
// Refresh user record so the new avatar persists across sessions
|
||||
await authStore.validateToken()
|
||||
} catch {
|
||||
avatarError = "Upload failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Avatar picker
|
||||
|
||||
@ViewBuilder
|
||||
private var avatarPicker: some View {
|
||||
PhotosPicker(selection: $photoPickerItem,
|
||||
matching: .images,
|
||||
photoLibrary: .shared()) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 72, height: 72)
|
||||
|
||||
if avatarUploading {
|
||||
ProgressView()
|
||||
.frame(width: 72, height: 72)
|
||||
} else if let urlStr = avatarURL ?? authStore.user?.avatarURL,
|
||||
let url = URL(string: urlStr) {
|
||||
KFImage(url)
|
||||
.placeholder {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(.amber)
|
||||
.frame(width: 72, height: 72)
|
||||
}
|
||||
|
||||
// Camera overlay badge
|
||||
if !avatarUploading {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.amber)
|
||||
.frame(width: 22, height: 22)
|
||||
Image(systemName: "camera.fill")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(.black)
|
||||
}
|
||||
.offset(x: 2, y: 2)
|
||||
}
|
||||
}
|
||||
.frame(width: 72, height: 72)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onChange(of: photoPickerItem) { _, item in
|
||||
guard let item else { return }
|
||||
Task { await loadImageForCrop(item) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +234,7 @@ struct ProfileView: View {
|
||||
}
|
||||
}
|
||||
),
|
||||
in: 0.5...3.0, step: 0.25
|
||||
in: 0.5...2.0, step: 0.25
|
||||
)
|
||||
.tint(.amber)
|
||||
}
|
||||
@@ -208,7 +321,8 @@ struct ChangePasswordView: View {
|
||||
body: Body(currentPassword: current, newPassword: newPwd)
|
||||
)
|
||||
success = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { dismiss() }
|
||||
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||||
dismiss()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
@@ -216,3 +330,10 @@ struct ChangePasswordView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Crop image item (Identifiable wrapper for .sheet(item:))
|
||||
|
||||
private struct CropImageItem: Identifiable {
|
||||
let id = UUID()
|
||||
let image: UIImage
|
||||
}
|
||||
|
||||
209
ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift
Normal file
209
ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift
Normal file
@@ -0,0 +1,209 @@
|
||||
import SwiftUI
|
||||
|
||||
struct UserProfileView: View {
|
||||
let username: String
|
||||
|
||||
@StateObject private var vm: UserProfileViewModel
|
||||
@EnvironmentObject private var authStore: AuthStore
|
||||
|
||||
init(username: String) {
|
||||
self.username = username
|
||||
_vm = StateObject(wrappedValue: UserProfileViewModel(username: username))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if vm.isLoading && vm.profile == nil {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 60)
|
||||
} else if let profile = vm.profile {
|
||||
profileHeader(profile)
|
||||
.padding(.bottom, 28)
|
||||
|
||||
if !vm.currentlyReading.isEmpty {
|
||||
ShelfHeader(title: "Currently Reading")
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
ForEach(vm.currentlyReading) { item in
|
||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||
ProfileBookCard(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
if !vm.library.isEmpty {
|
||||
ShelfHeader(title: "Library")
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
ForEach(vm.library) { item in
|
||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||
ProfileBookCard(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
if vm.currentlyReading.isEmpty && vm.library.isEmpty && !vm.isLoading {
|
||||
EmptyStateView(
|
||||
icon: "books.vertical",
|
||||
title: "No books yet",
|
||||
message: "\(username) hasn't read anything yet."
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 20)
|
||||
}
|
||||
} else if let err = vm.error {
|
||||
EmptyStateView(icon: "exclamationmark.triangle", title: "Error", message: err)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 60)
|
||||
}
|
||||
|
||||
Color.clear.frame(height: 20)
|
||||
}
|
||||
}
|
||||
.navigationTitle("@\(username)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await vm.load() }
|
||||
.refreshable { await vm.load() }
|
||||
.errorAlert($vm.error)
|
||||
}
|
||||
|
||||
// MARK: - Profile header
|
||||
|
||||
@ViewBuilder
|
||||
private func profileHeader(_ profile: PublicUserProfile) -> some View {
|
||||
VStack(alignment: .center, spacing: 16) {
|
||||
AvatarThumb(urlString: profile.avatarUrl, size: 80)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text("@\(profile.username)")
|
||||
.font(.title3.bold())
|
||||
if !profile.created.isEmpty {
|
||||
Text("Joined \(shortDate(profile.created))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Stats row
|
||||
HStack(spacing: 32) {
|
||||
VStack(spacing: 2) {
|
||||
Text("\(profile.followerCount)")
|
||||
.font(.subheadline.bold().monospacedDigit())
|
||||
Text("Followers")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
VStack(spacing: 2) {
|
||||
Text("\(profile.followingCount)")
|
||||
.font(.subheadline.bold().monospacedDigit())
|
||||
Text("Following")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Follow button — only shown for other users (not self)
|
||||
if !profile.isSelf && authStore.isAuthenticated {
|
||||
Button {
|
||||
Task { await vm.toggleSubscribe() }
|
||||
} label: {
|
||||
if vm.isTogglingSubscribe {
|
||||
ProgressView().controlSize(.small)
|
||||
.frame(width: 120, height: 34)
|
||||
} else if profile.isSubscribed {
|
||||
Label("Following", systemImage: "checkmark")
|
||||
.font(.subheadline.bold())
|
||||
.frame(width: 120, height: 34)
|
||||
} else {
|
||||
Text("Follow")
|
||||
.font(.subheadline.bold())
|
||||
.frame(width: 120, height: 34)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(profile.isSubscribed ? Color(.systemGray4) : .amber)
|
||||
.disabled(vm.isTogglingSubscribe)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 24)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
private func shortDate(_ iso: String) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ"
|
||||
if let date = formatter.date(from: iso) {
|
||||
let out = DateFormatter()
|
||||
out.dateStyle = .medium
|
||||
out.timeStyle = .none
|
||||
return out.string(from: date)
|
||||
}
|
||||
return String(iso.prefix(10))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shelf header
|
||||
|
||||
private struct ShelfHeader: View {
|
||||
let title: String
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(.title3.bold())
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Book card for profile shelves
|
||||
|
||||
private struct ProfileBookCard: View {
|
||||
let item: PublicLibraryItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
AsyncCoverImage(url: item.book.cover)
|
||||
.frame(width: 110, height: 158)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
|
||||
|
||||
// Chapter badge (if reading)
|
||||
if let ch = item.lastChapter, ch > 0 {
|
||||
Text("Ch.\(ch)")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(.black.opacity(0.85))
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 4)
|
||||
.background(Capsule().fill(Color.amber))
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
|
||||
Text(item.book.title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(2)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
|
||||
Text(item.book.author)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
247
ios/LibNovel/LibNovel/Views/Search/SearchView.swift
Normal file
247
ios/LibNovel/LibNovel/Views/Search/SearchView.swift
Normal file
@@ -0,0 +1,247 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - SearchView
|
||||
// Dedicated search tab modelled after Apple Books' Search screen.
|
||||
// Shows a prominent search bar; while idle displays recent searches and
|
||||
// trending/popular novels; after a query shows a results grid.
|
||||
|
||||
struct SearchView: View {
|
||||
@StateObject private var vm = SearchViewModel()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// ── Search bar ──────────────────────────────────────────────
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Search novels, authors…", text: $vm.query)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.submitLabel(.search)
|
||||
.onSubmit { vm.submitSearch() }
|
||||
if !vm.query.isEmpty {
|
||||
Button { vm.clear() } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
Divider()
|
||||
|
||||
// ── Content ─────────────────────────────────────────────────
|
||||
if vm.query.isEmpty && vm.results.isEmpty {
|
||||
idleContent
|
||||
} else if vm.isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if vm.results.isEmpty {
|
||||
EmptyStateView(
|
||||
icon: "magnifyingglass",
|
||||
title: "No results",
|
||||
message: "Try a different title or author name."
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
resultsGrid
|
||||
}
|
||||
}
|
||||
.navigationTitle("Search")
|
||||
.appNavigationDestination()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
AvatarToolbarButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Idle screen (recent searches + popular)
|
||||
|
||||
@ViewBuilder
|
||||
private var idleContent: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
// Recent searches
|
||||
if !vm.recentSearches.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text("Recent")
|
||||
.font(.title3.bold())
|
||||
Spacer()
|
||||
Button("Clear") { vm.clearRecent() }
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
ForEach(vm.recentSearches, id: \.self) { term in
|
||||
Button {
|
||||
vm.query = term
|
||||
vm.submitSearch()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "clock")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 20)
|
||||
Text(term)
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.left")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 11)
|
||||
}
|
||||
Divider().padding(.leading, 44)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Popular / trending novels (loaded from browse popular)
|
||||
if !vm.popular.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Popular")
|
||||
.font(.title3.bold())
|
||||
.padding(.horizontal)
|
||||
|
||||
LazyVGrid(
|
||||
columns: [
|
||||
GridItem(.flexible(), spacing: 12),
|
||||
GridItem(.flexible(), spacing: 12)
|
||||
],
|
||||
spacing: 16
|
||||
) {
|
||||
ForEach(vm.popular) { novel in
|
||||
NavigationLink(value: NavDestination.book(novel.slug)) {
|
||||
SearchNovelCard(novel: novel)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
Color.clear.frame(height: 20)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Results grid
|
||||
|
||||
@ViewBuilder
|
||||
private var resultsGrid: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(
|
||||
columns: [
|
||||
GridItem(.flexible(), spacing: 12),
|
||||
GridItem(.flexible(), spacing: 12)
|
||||
],
|
||||
spacing: 16
|
||||
) {
|
||||
ForEach(vm.results) { novel in
|
||||
NavigationLink(value: NavDestination.book(novel.slug)) {
|
||||
SearchNovelCard(novel: novel)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Search novel card (compact 2-column)
|
||||
|
||||
private struct SearchNovelCard: View {
|
||||
let novel: BrowseNovel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AsyncCoverImage(url: novel.cover)
|
||||
.frame(maxWidth: .infinity)
|
||||
.aspectRatio(2/3, contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
|
||||
.bookCoverZoomSource(slug: novel.slug)
|
||||
|
||||
Text(novel.title)
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SearchViewModel
|
||||
|
||||
@MainActor
|
||||
final class SearchViewModel: ObservableObject {
|
||||
@Published var query: String = ""
|
||||
@Published var results: [BrowseNovel] = []
|
||||
@Published var popular: [BrowseNovel] = []
|
||||
@Published var isLoading = false
|
||||
|
||||
// Persisted in UserDefaults (max 10 recent terms)
|
||||
@Published var recentSearches: [String] = []
|
||||
|
||||
private let recentKey = "searchRecentTerms"
|
||||
|
||||
init() {
|
||||
recentSearches = (UserDefaults.standard.stringArray(forKey: recentKey) ?? [])
|
||||
Task { await loadPopular() }
|
||||
}
|
||||
|
||||
func submitSearch() {
|
||||
let term = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !term.isEmpty else { return }
|
||||
saveRecent(term)
|
||||
Task { await runSearch(term) }
|
||||
}
|
||||
|
||||
func clear() {
|
||||
query = ""
|
||||
results = []
|
||||
}
|
||||
|
||||
func clearRecent() {
|
||||
recentSearches = []
|
||||
UserDefaults.standard.removeObject(forKey: recentKey)
|
||||
}
|
||||
|
||||
private func runSearch(_ term: String) async {
|
||||
isLoading = true
|
||||
do {
|
||||
let result = try await APIClient.shared.search(query: term)
|
||||
results = result.results
|
||||
} catch {
|
||||
results = []
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func loadPopular() async {
|
||||
do {
|
||||
let result = try await APIClient.shared.browse(page: 1, genre: "all", sort: "popular", status: "all")
|
||||
popular = Array(result.novels.prefix(12))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private func saveRecent(_ term: String) {
|
||||
var list = recentSearches.filter { $0 != term }
|
||||
list.insert(term, at: 0)
|
||||
if list.count > 10 { list = Array(list.prefix(10)) }
|
||||
recentSearches = list
|
||||
UserDefaults.standard.set(list, forKey: recentKey)
|
||||
}
|
||||
}
|
||||
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
|
||||
options:
|
||||
bundleIdPrefix: cc.kalekber
|
||||
bundleIdPrefix: com.kalekber
|
||||
deploymentTarget:
|
||||
iOS: "17.0"
|
||||
xcodeVersion: "16.0"
|
||||
@@ -23,10 +23,6 @@ settings:
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS: ""
|
||||
|
||||
packages:
|
||||
# Markdown rendering — renders chapter HTML from the server
|
||||
MarkdownUI:
|
||||
url: https://github.com/gonzalezreal/swift-markdown-ui
|
||||
from: "2.4.0"
|
||||
# Async image loading with caching
|
||||
Kingfisher:
|
||||
url: https://github.com/onevcat/Kingfisher
|
||||
@@ -46,14 +42,19 @@ targets:
|
||||
- path: LibNovel/Resources/Assets.xcassets
|
||||
dependencies:
|
||||
- package: Kingfisher
|
||||
- package: MarkdownUI
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: cc.kalekber.libnovel
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.kalekber.LibNovel
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
TARGETED_DEVICE_FAMILY: "1,2" # iPhone + iPad
|
||||
GENERATE_INFOPLIST_FILE: NO
|
||||
INFOPLIST_FILE: LibNovel/Resources/Info.plist
|
||||
configs:
|
||||
Release:
|
||||
CODE_SIGN_STYLE: Manual
|
||||
DEVELOPMENT_TEAM: GHZXC6FVMU
|
||||
CODE_SIGN_IDENTITY: "Apple Distribution"
|
||||
PROVISIONING_PROFILE: "af592c3a-f60b-4ac1-a14f-30b8a206017f"
|
||||
|
||||
LibNovelTests:
|
||||
type: bundle.unit-test
|
||||
@@ -65,7 +66,7 @@ targets:
|
||||
- target: LibNovel
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: cc.kalekber.libnovel.tests
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.kalekber.LibNovel.tests
|
||||
|
||||
schemes:
|
||||
LibNovel:
|
||||
|
||||
32
ios/LibNovel/test-build.sh
Executable file
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).
|
||||
# Output: {{runner_temp}}/LibNovel.xcarchive
|
||||
# Typically called from CI after importing certificate + provisioning profile.
|
||||
ios-archive: ios-gen ios-resolve
|
||||
# Usage: just ios-archive <team-id> <profile-uuid>
|
||||
ios-archive team_id profile_uuid: ios-gen ios-resolve
|
||||
cd {{ios_dir}} && xcodebuild archive \
|
||||
-project {{ios_scheme}}.xcodeproj \
|
||||
-scheme {{ios_scheme}} \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-clonedSourcePackagesDirPath {{ios_spm}} \
|
||||
-archivePath {{runner_temp}}/LibNovel.xcarchive
|
||||
-archivePath {{runner_temp}}/LibNovel.xcarchive \
|
||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
||||
"PROVISIONING_PROFILE[sdk=iphoneos*]={{profile_uuid}}" \
|
||||
DEVELOPMENT_TEAM="{{team_id}}"
|
||||
|
||||
# Export an IPA from the archive produced by ios-archive.
|
||||
# Requires ios/LibNovel/ExportOptions.plist.
|
||||
@@ -147,6 +152,23 @@ ios-export:
|
||||
-exportPath {{runner_temp}}/ipa \
|
||||
-exportOptionsPlist ExportOptions.plist
|
||||
|
||||
# Set the build number (CFBundleVersion) in project.yml before archiving.
|
||||
# Usage: just ios-set-build-number 42
|
||||
ios-set-build-number number:
|
||||
cd {{ios_dir}} && sed -i '' \
|
||||
's/CURRENT_PROJECT_VERSION: .*/CURRENT_PROJECT_VERSION: {{number}}/' \
|
||||
project.yml
|
||||
|
||||
# Upload the exported IPA to TestFlight via App Store Connect API.
|
||||
# Requires env vars: ASC_KEY_ID, ASC_ISSUER_ID, ASC_PRIVATE_KEY_PATH
|
||||
# The private key (.p8 file) must be present at ASC_PRIVATE_KEY_PATH.
|
||||
ios-upload:
|
||||
xcrun altool --upload-app \
|
||||
--type ios \
|
||||
--file {{runner_temp}}/ipa/LibNovel.ipa \
|
||||
--apiKey "$ASC_KEY_ID" \
|
||||
--apiIssuer "$ASC_ISSUER_ID"
|
||||
|
||||
# ─── Docker Compose ───────────────────────────────────────────────────────────
|
||||
|
||||
# Start all services (browserless, kokoro, scraper, minio, pocketbase)
|
||||
|
||||
@@ -9,8 +9,14 @@ RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# Build-time version info — injected by docker-compose or CI via --build-arg.
|
||||
ARG VERSION=dev
|
||||
ARG COMMIT=unknown
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -ldflags="-s -w" -o /scraper ./cmd/scraper
|
||||
go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION} -X main.Commit=${COMMIT}" \
|
||||
-o /scraper ./cmd/scraper
|
||||
|
||||
# ── Runtime stage ──────────────────────────────────────────────────────────────
|
||||
FROM alpine:3.21
|
||||
|
||||
@@ -47,6 +47,13 @@ import (
|
||||
"github.com/libnovel/scraper/internal/storage"
|
||||
)
|
||||
|
||||
// Build-time version info — injected via -ldflags during docker build.
|
||||
// Falls back to "dev" / "unknown" when built without -ldflags (local dev).
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logLevel := slog.LevelInfo
|
||||
if v := os.Getenv("LOG_LEVEL"); v != "" {
|
||||
@@ -95,6 +102,7 @@ func run(log *slog.Logger) error {
|
||||
BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"),
|
||||
BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"),
|
||||
BucketBrowse: envOr("MINIO_BUCKET_BROWSE", "libnovel-browse"),
|
||||
BucketAvatars: envOr("MINIO_BUCKET_AVATARS", "libnovel-avatars"),
|
||||
}
|
||||
pbCfg := storage.PocketBaseConfig{
|
||||
BaseURL: envOr("POCKETBASE_URL", "http://localhost:8090"),
|
||||
@@ -182,7 +190,7 @@ func run(log *slog.Logger) error {
|
||||
"pocketbase_url", pbCfg.BaseURL,
|
||||
"pocketbase_email", pbCfg.AdminEmail,
|
||||
)
|
||||
srv := server.New(addr, oCfg, nf, log, store, kokoroURL, kokoroVoice)
|
||||
srv := server.New(addr, oCfg, nf, log, store, kokoroURL, kokoroVoice, Version, Commit)
|
||||
return srv.ListenAndServe(ctx)
|
||||
|
||||
case "save-browse":
|
||||
|
||||
@@ -101,7 +101,7 @@ func newE2EFixture(t *testing.T) *e2eFixture {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg)
|
||||
hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg, slog.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("NewHybridStore: %v", err)
|
||||
}
|
||||
|
||||
@@ -151,6 +151,13 @@ func (s *mockStore) PresignChapter(_ context.Context, _ string, _ int, _ time.Du
|
||||
func (s *mockStore) PresignAudio(_ context.Context, _ string, _ time.Duration) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (s *mockStore) PresignAvatarUpload(_ context.Context, _, _ string) (string, string, error) {
|
||||
return "", "", nil
|
||||
}
|
||||
func (s *mockStore) PresignAvatarURL(_ context.Context, _ string) (string, bool, error) {
|
||||
return "", false, nil
|
||||
}
|
||||
func (s *mockStore) DeleteAvatar(_ context.Context, _ string) error { return nil }
|
||||
func (s *mockStore) SaveBrowsePage(_ context.Context, _, _ string) error { return nil }
|
||||
func (s *mockStore) GetBrowsePage(_ context.Context, _ string) (string, bool, error) {
|
||||
return "", false, nil
|
||||
|
||||
@@ -649,3 +649,64 @@ func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"url": url})
|
||||
}
|
||||
|
||||
// handlePresignAvatarUpload handles GET /api/presign/avatar-upload/{userId}.
|
||||
// Returns a short-lived presigned PUT URL for uploading an avatar image directly
|
||||
// to MinIO, along with the object key to record in PocketBase after the upload.
|
||||
// Query param: ext — image extension (jpg, png, webp). Defaults to "jpg".
|
||||
func (s *Server) handlePresignAvatarUpload(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.PathValue("userId")
|
||||
if userID == "" {
|
||||
http.Error(w, `{"error":"missing userId"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ext := r.URL.Query().Get("ext")
|
||||
switch ext {
|
||||
case "jpg", "jpeg":
|
||||
ext = "jpg"
|
||||
case "png":
|
||||
ext = "png"
|
||||
case "webp":
|
||||
ext = "webp"
|
||||
default:
|
||||
ext = "jpg"
|
||||
}
|
||||
|
||||
uploadURL, key, err := s.store.PresignAvatarUpload(r.Context(), userID, ext)
|
||||
if err != nil {
|
||||
s.log.Error("presign avatar upload failed", "userId", userID, "err", err)
|
||||
http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"upload_url": uploadURL,
|
||||
"key": key,
|
||||
})
|
||||
}
|
||||
|
||||
// handlePresignAvatar handles GET /api/presign/avatar/{userId}.
|
||||
// Returns a presigned GET URL for a user's existing avatar, or 404 if none.
|
||||
func (s *Server) handlePresignAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.PathValue("userId")
|
||||
if userID == "" {
|
||||
http.Error(w, `{"error":"missing userId"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
url, found, err := s.store.PresignAvatarURL(r.Context(), userID)
|
||||
if err != nil {
|
||||
s.log.Error("presign avatar failed", "userId", userID, "err", err)
|
||||
http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"url": url})
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func newTestStore(t *testing.T) *storage.HybridStore {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg)
|
||||
hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg, slog.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("NewHybridStore: %v", err)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ type Server struct {
|
||||
running bool
|
||||
kokoroURL string // Kokoro-FastAPI base URL, e.g. http://kokoro:8880
|
||||
kokoroVoice string // default voice, e.g. af_bella
|
||||
version string // semver tag, e.g. "v1.2.3" (set via ldflags)
|
||||
commit string // short git SHA (set via ldflags)
|
||||
|
||||
// voiceMu guards cachedVoices.
|
||||
voiceMu sync.RWMutex
|
||||
@@ -74,7 +76,7 @@ type browseCacheEntry struct {
|
||||
}
|
||||
|
||||
// New creates a new Server.
|
||||
func New(addr string, oCfg orchestrator.Config, novel scraper.NovelScraper, log *slog.Logger, store storage.Store, kokoroURL, kokoroVoice string) *Server {
|
||||
func New(addr string, oCfg orchestrator.Config, novel scraper.NovelScraper, log *slog.Logger, store storage.Store, kokoroURL, kokoroVoice, version, commit string) *Server {
|
||||
return &Server{
|
||||
addr: addr,
|
||||
oCfg: oCfg,
|
||||
@@ -83,6 +85,8 @@ func New(addr string, oCfg orchestrator.Config, novel scraper.NovelScraper, log
|
||||
store: store,
|
||||
kokoroURL: kokoroURL,
|
||||
kokoroVoice: kokoroVoice,
|
||||
version: version,
|
||||
commit: commit,
|
||||
audioJobIDs: make(map[string]string),
|
||||
browseInFlight: make(map[string]struct{}),
|
||||
browseMemCache: make(map[string]browseCacheEntry),
|
||||
@@ -138,6 +142,7 @@ func (s *Server) voices() []string {
|
||||
func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", s.handleHealth)
|
||||
mux.HandleFunc("GET /api/version", s.handleVersion)
|
||||
mux.HandleFunc("POST /scrape", s.handleScrapeCatalogue)
|
||||
mux.HandleFunc("POST /scrape/book", s.handleScrapeBook)
|
||||
mux.HandleFunc("POST /scrape/book/range", s.handleScrapeBookRange)
|
||||
@@ -165,6 +170,8 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("GET /api/presign/chapter/{slug}/{n}", s.handlePresignChapter)
|
||||
mux.HandleFunc("GET /api/presign/audio/{slug}/{n}", s.handlePresignAudio)
|
||||
mux.HandleFunc("GET /api/presign/voice-sample/{voice}", s.handlePresignVoiceSample)
|
||||
mux.HandleFunc("GET /api/presign/avatar-upload/{userId}", s.handlePresignAvatarUpload)
|
||||
mux.HandleFunc("GET /api/presign/avatar/{userId}", s.handlePresignAvatar)
|
||||
// Plain-text chapter content (used server-side for audio generation)
|
||||
mux.HandleFunc("GET /api/chapter-text/{slug}/{n}", s.handleChapterText)
|
||||
// Voices list (proxied from Kokoro)
|
||||
@@ -220,7 +227,19 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
"version": s.version,
|
||||
"commit": s.commit,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleVersion(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"version": s.version,
|
||||
"commit": s.commit,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Session cookie helpers ───────────────────────────────────────────────────
|
||||
|
||||
@@ -316,6 +316,18 @@ func (h *HybridStore) PresignAudio(ctx context.Context, key string, expires time
|
||||
return h.minio.PresignAudio(ctx, key, expires)
|
||||
}
|
||||
|
||||
func (h *HybridStore) PresignAvatarUpload(ctx context.Context, userID, ext string) (string, string, error) {
|
||||
return h.minio.PresignAvatarUploadURL(ctx, userID, ext)
|
||||
}
|
||||
|
||||
func (h *HybridStore) PresignAvatarURL(ctx context.Context, userID string) (string, bool, error) {
|
||||
return h.minio.PresignAvatarURL(ctx, userID)
|
||||
}
|
||||
|
||||
func (h *HybridStore) DeleteAvatar(ctx context.Context, userID string) error {
|
||||
return h.minio.DeleteAvatar(ctx, userID)
|
||||
}
|
||||
|
||||
// ─── Browse page snapshots ────────────────────────────────────────────────────
|
||||
|
||||
func (h *HybridStore) SaveBrowsePage(ctx context.Context, key, html string) error {
|
||||
|
||||
@@ -23,6 +23,7 @@ type MinioConfig struct {
|
||||
BucketChapters string // e.g. "libnovel-chapters"
|
||||
BucketAudio string // e.g. "libnovel-audio"
|
||||
BucketBrowse string // e.g. "libnovel-browse"
|
||||
BucketAvatars string // e.g. "libnovel-avatars"
|
||||
}
|
||||
|
||||
// MinioClient wraps a minio.Client and exposes object operations for
|
||||
@@ -59,7 +60,7 @@ func NewMinioClient(ctx context.Context, cfg MinioConfig) (*MinioClient, error)
|
||||
}
|
||||
|
||||
mc := &MinioClient{c: c, pub: pub, cfg: cfg}
|
||||
for _, bucket := range []string{cfg.BucketChapters, cfg.BucketAudio, cfg.BucketBrowse} {
|
||||
for _, bucket := range []string{cfg.BucketChapters, cfg.BucketAudio, cfg.BucketBrowse, cfg.BucketAvatars} {
|
||||
if bucket == "" {
|
||||
continue
|
||||
}
|
||||
@@ -336,3 +337,77 @@ func sanitiseVoice(voice string) string {
|
||||
return '_'
|
||||
}, voice)
|
||||
}
|
||||
|
||||
// ─── Avatar objects ───────────────────────────────────────────────────────────
|
||||
|
||||
// avatarKey returns the MinIO object key for a user avatar.
|
||||
// Layout: avatars/{userId}.{ext}
|
||||
func avatarKey(userID, ext string) string {
|
||||
return fmt.Sprintf("avatars/%s.%s", userID, ext)
|
||||
}
|
||||
|
||||
// PutAvatar stores an avatar image in the avatars bucket.
|
||||
// ext should be "jpg", "png", or "webp".
|
||||
func (m *MinioClient) PutAvatar(ctx context.Context, userID, ext string, data []byte, contentType string) error {
|
||||
if m.cfg.BucketAvatars == "" {
|
||||
return fmt.Errorf("minio: avatars bucket not configured")
|
||||
}
|
||||
key := avatarKey(userID, ext)
|
||||
_, err := m.c.PutObject(ctx, m.cfg.BucketAvatars, key,
|
||||
bytes.NewReader(data), int64(len(data)),
|
||||
minio.PutObjectOptions{ContentType: contentType})
|
||||
if err != nil {
|
||||
return fmt.Errorf("minio: put avatar %s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PresignAvatarUploadURL returns a presigned PUT URL for uploading an avatar image
|
||||
// directly to MinIO from the client. Signed with the public endpoint so iOS/browser
|
||||
// can PUT bytes straight to MinIO without routing through the server.
|
||||
// ext should be "jpg", "png", or "webp". Expires in 15 minutes.
|
||||
func (m *MinioClient) PresignAvatarUploadURL(ctx context.Context, userID, ext string) (string, string, error) {
|
||||
if m.cfg.BucketAvatars == "" {
|
||||
return "", "", fmt.Errorf("minio: avatars bucket not configured")
|
||||
}
|
||||
key := avatarKey(userID, ext)
|
||||
u, err := m.pub.PresignedPutObject(ctx, m.cfg.BucketAvatars, key, 15*time.Minute)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("minio: presign avatar upload %s: %w", key, err)
|
||||
}
|
||||
return u.String(), key, nil
|
||||
}
|
||||
|
||||
// PresignAvatarURL returns a presigned GET URL for a user avatar.
|
||||
// Returns ("", false, nil) when no avatar exists for the given userID.
|
||||
func (m *MinioClient) PresignAvatarURL(ctx context.Context, userID string) (string, bool, error) {
|
||||
if m.cfg.BucketAvatars == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
// Try common extensions in order of preference.
|
||||
for _, ext := range []string{"jpg", "png", "webp", "gif"} {
|
||||
key := avatarKey(userID, ext)
|
||||
_, statErr := m.c.StatObject(ctx, m.cfg.BucketAvatars, key, minio.StatObjectOptions{})
|
||||
if statErr != nil {
|
||||
continue
|
||||
}
|
||||
u, err := m.pub.PresignedGetObject(ctx, m.cfg.BucketAvatars, key, 24*time.Hour, nil)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("minio: presign avatar %s: %w", key, err)
|
||||
}
|
||||
return u.String(), true, nil
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// DeleteAvatar removes any existing avatar for the given userID (all extensions).
|
||||
func (m *MinioClient) DeleteAvatar(ctx context.Context, userID string) error {
|
||||
if m.cfg.BucketAvatars == "" {
|
||||
return nil
|
||||
}
|
||||
for _, ext := range []string{"jpg", "png", "webp", "gif"} {
|
||||
key := avatarKey(userID, ext)
|
||||
_ = m.c.RemoveObject(ctx, m.cfg.BucketAvatars, key, minio.RemoveObjectOptions{})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
// started(date), finished(date), error_message(text)
|
||||
// user_sessions — user_id(text), session_id(text,unique), user_agent(text),
|
||||
// ip(text), created_at(date), last_seen(date)
|
||||
// book_comments — slug(text), user_id(text), username(text), body(text),
|
||||
// upvotes(number), downvotes(number), created(date)
|
||||
// comment_votes — comment_id(text), user_id(text), session_id(text), vote(text: up|down)
|
||||
package storage
|
||||
|
||||
import (
|
||||
@@ -422,6 +425,40 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error {
|
||||
{"name": "last_seen", "type": "date"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "book_comments",
|
||||
"type": "base",
|
||||
"fields": []map[string]interface{}{
|
||||
{"name": "slug", "type": "text", "required": true},
|
||||
{"name": "user_id", "type": "text"},
|
||||
{"name": "username", "type": "text"},
|
||||
{"name": "body", "type": "text", "required": true},
|
||||
{"name": "upvotes", "type": "number"},
|
||||
{"name": "downvotes", "type": "number"},
|
||||
{"name": "created", "type": "date"},
|
||||
{"name": "parent_id", "type": "text"}, // empty = top-level; set = reply to that comment ID
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "comment_votes",
|
||||
"type": "base",
|
||||
"fields": []map[string]interface{}{
|
||||
{"name": "comment_id", "type": "text", "required": true},
|
||||
{"name": "user_id", "type": "text"},
|
||||
{"name": "session_id", "type": "text", "required": true},
|
||||
{"name": "vote", "type": "text", "required": true}, // "up" | "down"
|
||||
},
|
||||
},
|
||||
{
|
||||
// follower_id follows followee_id
|
||||
"name": "user_subscriptions",
|
||||
"type": "base",
|
||||
"fields": []map[string]interface{}{
|
||||
{"name": "follower_id", "type": "text", "required": true},
|
||||
{"name": "followee_id", "type": "text", "required": true},
|
||||
{"name": "created", "type": "date"},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, col := range collections {
|
||||
name, _ := col["name"].(string)
|
||||
@@ -458,6 +495,10 @@ type migration struct {
|
||||
var migrations = []migration{
|
||||
// user_id was added to progress after initial deploy.
|
||||
{"progress", "user_id", "text"},
|
||||
// avatar_url stores the MinIO presign path for the user's profile picture.
|
||||
{"app_users", "avatar_url", "text"},
|
||||
// parent_id enables 1-level comment nesting (replies). Empty = top-level comment.
|
||||
{"book_comments", "parent_id", "text"},
|
||||
}
|
||||
|
||||
// EnsureMigrations idempotently adds any fields that are missing from existing
|
||||
|
||||
@@ -160,6 +160,17 @@ type Store interface {
|
||||
// PresignAudio returns a presigned GET URL for an audio object.
|
||||
PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error)
|
||||
|
||||
// PresignAvatarUpload returns a short-lived presigned PUT URL for uploading
|
||||
// an avatar image directly to MinIO, and the object key that will be stored.
|
||||
// ext should be "jpg", "png", or "webp".
|
||||
PresignAvatarUpload(ctx context.Context, userID, ext string) (uploadURL, key string, err error)
|
||||
|
||||
// PresignAvatarURL returns a presigned GET URL for a user's avatar, or ("", false, nil) if none.
|
||||
PresignAvatarURL(ctx context.Context, userID string) (string, bool, error)
|
||||
|
||||
// DeleteAvatar removes all avatar objects for a user (all extensions).
|
||||
DeleteAvatar(ctx context.Context, userID string) error
|
||||
|
||||
// ── Browse page snapshots (MinIO) ──────────────────────────────────────
|
||||
|
||||
// SaveBrowsePage stores a SingleFile HTML snapshot for the given cache key.
|
||||
|
||||
13
scripts/.runner
Normal file
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": "password_hash", "type": "text", "required": true},
|
||||
{"name": "role", "type": "text"},
|
||||
{"name": "created", "type": "date"}
|
||||
{"name": "created", "type": "date"},
|
||||
{"name": "avatar_url", "type": "text"}
|
||||
]
|
||||
}'
|
||||
|
||||
@@ -200,5 +201,32 @@ create_collection "user_settings" '{
|
||||
ensure_field "progress" "user_id" "text"
|
||||
ensure_field "progress" "audio_time" "number"
|
||||
ensure_field "user_settings" "user_id" "text"
|
||||
ensure_field "app_users" "avatar_url" "text"
|
||||
|
||||
create_collection "book_comments" '{
|
||||
"name": "book_comments",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "slug", "type": "text", "required": true},
|
||||
{"name": "user_id", "type": "text"},
|
||||
{"name": "username", "type": "text"},
|
||||
{"name": "body", "type": "text", "required": true},
|
||||
{"name": "upvotes", "type": "number"},
|
||||
{"name": "downvotes", "type": "number"},
|
||||
{"name": "created", "type": "date"},
|
||||
{"name": "parent_id", "type": "text"}
|
||||
]
|
||||
}'
|
||||
|
||||
create_collection "comment_votes" '{
|
||||
"name": "comment_votes",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "comment_id", "type": "text", "required": true},
|
||||
{"name": "user_id", "type": "text"},
|
||||
{"name": "session_id", "type": "text", "required": true},
|
||||
{"name": "vote", "type": "text", "required": true}
|
||||
]
|
||||
}'
|
||||
|
||||
log "all collections ready"
|
||||
|
||||
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! ==="
|
||||
@@ -5,19 +5,23 @@ COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
# Build-time version info — injected by docker-compose or CI via --build-arg.
|
||||
ARG BUILD_VERSION=dev
|
||||
ARG BUILD_COMMIT=unknown
|
||||
|
||||
# Expose as PUBLIC_ env vars so SvelteKit's $env/dynamic/public can read them.
|
||||
ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
|
||||
ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ── Runtime image ──────────────────────────────────────────────────────────────
|
||||
# adapter-node bundles all dependencies into build/ — no npm install needed.
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# adapter-node produces a standalone build/
|
||||
COPY --from=builder /app/build ./build
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/package-lock.json ./
|
||||
|
||||
# Install production dependencies (e.g. marked) that are imported at runtime
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
@@ -25,3 +29,4 @@ ENV HOST=0.0.0.0
|
||||
|
||||
EXPOSE $PORT
|
||||
CMD ["node", "build"]
|
||||
|
||||
|
||||
1680
ui/package-lock.json
generated
1680
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,9 @@
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1005.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1005.0",
|
||||
"cropperjs": "^1.6.2",
|
||||
"marked": "^17.0.3",
|
||||
"pocketbase": "^0.26.8"
|
||||
}
|
||||
|
||||
116
ui/src/lib/components/AvatarCropModal.svelte
Normal file
116
ui/src/lib/components/AvatarCropModal.svelte
Normal file
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import Cropper from 'cropperjs';
|
||||
import 'cropperjs/dist/cropper.css';
|
||||
|
||||
interface Props {
|
||||
file: File;
|
||||
onconfirm: (blob: Blob, mimeType: string) => void;
|
||||
oncancel: () => void;
|
||||
}
|
||||
|
||||
let { file, onconfirm, oncancel }: Props = $props();
|
||||
|
||||
let imgEl: HTMLImageElement | undefined = $state();
|
||||
let cropper: Cropper | null = null;
|
||||
let objectUrl = '';
|
||||
|
||||
// Initialize cropper once the img element is bound and the file is known.
|
||||
// Use a $effect so it runs after the DOM is ready (replaces onMount).
|
||||
$effect(() => {
|
||||
if (!imgEl || !file) return;
|
||||
|
||||
// Create the object URL and set src directly on the element (not via reactive
|
||||
// state) so cropperjs sees the correct src before the image load event fires.
|
||||
objectUrl = URL.createObjectURL(file);
|
||||
imgEl.src = objectUrl;
|
||||
|
||||
// Cropperjs must be initialised inside the image's load event so it can
|
||||
// measure the natural dimensions — if we call new Cropper() before the image
|
||||
// has loaded, the crop canvas is blank/invisible.
|
||||
const handleLoad = () => {
|
||||
cropper = new Cropper(imgEl!, {
|
||||
aspectRatio: 1,
|
||||
viewMode: 1,
|
||||
dragMode: 'move',
|
||||
autoCropArea: 0.8,
|
||||
restore: false,
|
||||
guides: false,
|
||||
center: true,
|
||||
highlight: false,
|
||||
cropBoxMovable: true,
|
||||
cropBoxResizable: true,
|
||||
toggleDragModeOnDblclick: false,
|
||||
background: false
|
||||
});
|
||||
};
|
||||
|
||||
imgEl.addEventListener('load', handleLoad, { once: true });
|
||||
|
||||
return () => {
|
||||
imgEl?.removeEventListener('load', handleLoad);
|
||||
cropper?.destroy();
|
||||
cropper = null;
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
objectUrl = '';
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
cropper?.destroy();
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
});
|
||||
|
||||
function confirm() {
|
||||
if (!cropper) return;
|
||||
const canvas = cropper.getCroppedCanvas({ width: 400, height: 400 });
|
||||
const mimeType = file.type === 'image/webp' ? 'image/webp' : 'image/jpeg';
|
||||
canvas.toBlob(
|
||||
(blob: Blob | null) => {
|
||||
if (blob) onconfirm(blob, mimeType);
|
||||
},
|
||||
mimeType,
|
||||
0.9
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Crop profile picture"
|
||||
>
|
||||
<div class="bg-zinc-900 rounded-2xl border border-zinc-700 shadow-2xl w-full max-w-sm flex flex-col gap-4 p-5">
|
||||
<h2 class="text-base font-semibold text-zinc-100">Crop profile picture</h2>
|
||||
|
||||
<!-- Cropper image container — overflow must be visible so cropperjs can
|
||||
render the crop canvas outside the natural image bounds. The fixed
|
||||
height gives cropperjs a stable container to size itself against. -->
|
||||
<div class="rounded-xl bg-zinc-800" style="height: 300px; position: relative;">
|
||||
<img
|
||||
bind:this={imgEl}
|
||||
alt="Crop preview"
|
||||
style="display:block; max-width:100%; max-height:100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-zinc-500 text-center">Drag to reposition · pinch or scroll to zoom · drag corners to resize</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={oncancel}
|
||||
class="flex-1 py-2 rounded-lg border border-zinc-600 text-zinc-300 text-sm font-medium hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={confirm}
|
||||
class="flex-1 py-2 rounded-lg bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Use photo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
560
ui/src/lib/components/CommentsSection.svelte
Normal file
560
ui/src/lib/components/CommentsSection.svelte
Normal file
@@ -0,0 +1,560 @@
|
||||
<script lang="ts">
|
||||
interface BookComment {
|
||||
id: string;
|
||||
slug: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
body: string;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
created: string;
|
||||
parent_id?: string;
|
||||
replies?: BookComment[];
|
||||
}
|
||||
|
||||
let {
|
||||
slug,
|
||||
isLoggedIn = false,
|
||||
currentUserId = ''
|
||||
}: {
|
||||
slug: string;
|
||||
isLoggedIn?: boolean;
|
||||
currentUserId?: string;
|
||||
} = $props();
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
let comments = $state<BookComment[]>([]);
|
||||
let myVotes = $state<Record<string, 'up' | 'down'>>({});
|
||||
let avatarUrls = $state<Record<string, string>>({});
|
||||
let loading = $state(true);
|
||||
let loadError = $state('');
|
||||
|
||||
// Top-level new comment
|
||||
let newBody = $state('');
|
||||
let posting = $state(false);
|
||||
let postError = $state('');
|
||||
|
||||
// Sort
|
||||
let sort = $state<'new' | 'top'>('top');
|
||||
|
||||
// Reply state: which comment is being replied to
|
||||
let replyingTo = $state<string | null>(null); // comment id
|
||||
let replyBody = $state('');
|
||||
let replyPosting = $state(false);
|
||||
let replyError = $state('');
|
||||
|
||||
// Delete in-flight set
|
||||
let deletingIds = $state(new Set<string>());
|
||||
|
||||
// Per-comment vote inflight set (prevents double-clicks)
|
||||
let votingIds = $state(new Set<string>());
|
||||
|
||||
// ── Load comments ─────────────────────────────────────────────────────────
|
||||
async function loadComments() {
|
||||
loading = true;
|
||||
loadError = '';
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/comments/${encodeURIComponent(slug)}?sort=${sort}`
|
||||
);
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
const data = await res.json();
|
||||
comments = data.comments ?? [];
|
||||
myVotes = data.myVotes ?? {};
|
||||
avatarUrls = data.avatarUrls ?? {};
|
||||
} catch (e) {
|
||||
loadError = 'Failed to load comments.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadComments();
|
||||
});
|
||||
|
||||
// Re-load when sort changes (after initial mount)
|
||||
let firstLoad = true;
|
||||
$effect(() => {
|
||||
// Read sort to create a dependency
|
||||
const _ = sort;
|
||||
if (firstLoad) { firstLoad = false; return; }
|
||||
loadComments();
|
||||
});
|
||||
|
||||
// ── Post top-level comment ────────────────────────────────────────────────
|
||||
async function postComment() {
|
||||
const text = newBody.trim();
|
||||
if (!text || posting) return;
|
||||
if (text.length > 2000) { postError = 'Comment is too long (max 2000 characters).'; return; }
|
||||
posting = true;
|
||||
postError = '';
|
||||
try {
|
||||
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: text })
|
||||
});
|
||||
if (res.status === 401) { postError = 'You must be logged in to comment.'; return; }
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
postError = err.message ?? 'Failed to post comment.';
|
||||
return;
|
||||
}
|
||||
const created: BookComment = await res.json();
|
||||
created.replies = [];
|
||||
// Prepend for 'new', or re-sort for 'top'
|
||||
if (sort === 'new') {
|
||||
comments = [created, ...comments];
|
||||
} else {
|
||||
comments = [created, ...comments]; // new comment has 0 score, goes to end after sort would happen
|
||||
}
|
||||
newBody = '';
|
||||
} catch {
|
||||
postError = 'Failed to post comment.';
|
||||
} finally {
|
||||
posting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Post reply ────────────────────────────────────────────────────────────
|
||||
async function postReply(parentId: string) {
|
||||
const text = replyBody.trim();
|
||||
if (!text || replyPosting) return;
|
||||
if (text.length > 2000) { replyError = 'Reply is too long (max 2000 characters).'; return; }
|
||||
replyPosting = true;
|
||||
replyError = '';
|
||||
try {
|
||||
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: text, parent_id: parentId })
|
||||
});
|
||||
if (res.status === 401) { replyError = 'You must be logged in to reply.'; return; }
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
replyError = err.message ?? 'Failed to post reply.';
|
||||
return;
|
||||
}
|
||||
const created: BookComment = await res.json();
|
||||
// Append to the parent's replies list
|
||||
comments = comments.map((c) => {
|
||||
if (c.id !== parentId) return c;
|
||||
return { ...c, replies: [...(c.replies ?? []), created] };
|
||||
});
|
||||
replyBody = '';
|
||||
replyingTo = null;
|
||||
} catch {
|
||||
replyError = 'Failed to post reply.';
|
||||
} finally {
|
||||
replyPosting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────
|
||||
async function deleteComment(commentId: string, parentId?: string) {
|
||||
if (deletingIds.has(commentId)) return;
|
||||
deletingIds = new Set([...deletingIds, commentId]);
|
||||
try {
|
||||
const res = await fetch(`/api/comment/${commentId}`, { method: 'DELETE' });
|
||||
if (!res.ok) return;
|
||||
if (parentId) {
|
||||
// Remove reply from parent
|
||||
comments = comments.map((c) => {
|
||||
if (c.id !== parentId) return c;
|
||||
return { ...c, replies: (c.replies ?? []).filter((r) => r.id !== commentId) };
|
||||
});
|
||||
} else {
|
||||
// Remove top-level comment
|
||||
comments = comments.filter((c) => c.id !== commentId);
|
||||
}
|
||||
} finally {
|
||||
const next = new Set(deletingIds);
|
||||
next.delete(commentId);
|
||||
deletingIds = next;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vote ──────────────────────────────────────────────────────────────────
|
||||
async function vote(commentId: string, v: 'up' | 'down', parentId?: string) {
|
||||
if (votingIds.has(commentId)) return;
|
||||
votingIds = new Set([...votingIds, commentId]);
|
||||
try {
|
||||
const res = await fetch(`/api/comment/${commentId}/vote`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vote: v })
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const updated: BookComment = await res.json();
|
||||
// Update comment in list (handle both top-level and replies)
|
||||
if (parentId) {
|
||||
comments = comments.map((c) => {
|
||||
if (c.id !== parentId) return c;
|
||||
return {
|
||||
...c,
|
||||
replies: (c.replies ?? []).map((r) => (r.id === commentId ? updated : r))
|
||||
};
|
||||
});
|
||||
} else {
|
||||
comments = comments.map((c) => (c.id === commentId ? { ...updated, replies: c.replies } : c));
|
||||
}
|
||||
// Update myVotes: toggle off if same, else set new vote
|
||||
const prev = myVotes[commentId];
|
||||
if (prev === v) {
|
||||
const next = { ...myVotes };
|
||||
delete next[commentId];
|
||||
myVotes = next;
|
||||
} else {
|
||||
myVotes = { ...myVotes, [commentId]: v };
|
||||
}
|
||||
} finally {
|
||||
const next = new Set(votingIds);
|
||||
next.delete(commentId);
|
||||
votingIds = next;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
function initials(username: string): string {
|
||||
const name = username.trim() || '?';
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
const date = new Date(iso);
|
||||
const now = Date.now();
|
||||
const diffMs = now - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60_000);
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays < 30) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
const charCount = $derived(newBody.length);
|
||||
const charOver = $derived(charCount > 2000);
|
||||
const replyCharCount = $derived(replyBody.length);
|
||||
const replyCharOver = $derived(replyCharCount > 2000);
|
||||
|
||||
const totalCount = $derived(
|
||||
comments.reduce((n, c) => n + 1 + (c.replies?.length ?? 0), 0)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="mt-10">
|
||||
<!-- Header + sort controls -->
|
||||
<div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
|
||||
<h2 class="text-base font-semibold text-zinc-200">
|
||||
Comments
|
||||
{#if !loading && totalCount > 0}
|
||||
<span class="text-zinc-500 font-normal text-sm ml-1">({totalCount})</span>
|
||||
{/if}
|
||||
</h2>
|
||||
|
||||
<!-- Sort tabs -->
|
||||
{#if !loading && comments.length > 0}
|
||||
<div class="flex items-center gap-1 text-xs rounded-lg bg-zinc-800/60 p-1">
|
||||
<button
|
||||
onclick={() => (sort = 'top')}
|
||||
class="px-2.5 py-1 rounded-md transition-colors {sort === 'top'
|
||||
? 'bg-zinc-700 text-zinc-100'
|
||||
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
Top
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (sort = 'new')}
|
||||
class="px-2.5 py-1 rounded-md transition-colors {sort === 'new'
|
||||
? 'bg-zinc-700 text-zinc-100'
|
||||
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Post form -->
|
||||
<div class="mb-6">
|
||||
{#if isLoggedIn}
|
||||
<div class="flex flex-col gap-2">
|
||||
<textarea
|
||||
bind:value={newBody}
|
||||
placeholder="Write a comment…"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-zinc-200 text-sm placeholder-zinc-500 resize-none focus:outline-none focus:border-amber-400 transition-colors"
|
||||
></textarea>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-xs {charOver ? 'text-red-400' : 'text-zinc-600'} tabular-nums">
|
||||
{charCount}/2000
|
||||
</span>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if postError}
|
||||
<span class="text-xs text-red-400">{postError}</span>
|
||||
{/if}
|
||||
<button
|
||||
onclick={postComment}
|
||||
disabled={posting || !newBody.trim() || charOver}
|
||||
class="px-4 py-1.5 rounded-lg text-sm font-medium transition-colors
|
||||
{posting || !newBody.trim() || charOver
|
||||
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
|
||||
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300'}"
|
||||
>
|
||||
{posting ? 'Posting…' : 'Post'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-zinc-500">
|
||||
<a href="/auth/login" class="text-amber-400 hover:text-amber-300 transition-colors">Log in</a>
|
||||
to leave a comment.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Comment list -->
|
||||
{#if loading}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each Array(3) as _}
|
||||
<div class="rounded-lg bg-zinc-800/50 p-4 animate-pulse">
|
||||
<div class="h-3 w-24 bg-zinc-700 rounded mb-3"></div>
|
||||
<div class="h-3 w-full bg-zinc-700/60 rounded mb-2"></div>
|
||||
<div class="h-3 w-3/4 bg-zinc-700/60 rounded"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if loadError}
|
||||
<p class="text-sm text-red-400">{loadError}</p>
|
||||
{:else if comments.length === 0}
|
||||
<p class="text-sm text-zinc-500">No comments yet. Be the first!</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each comments as comment (comment.id)}
|
||||
{@const myVote = myVotes[comment.id]}
|
||||
{@const voting = votingIds.has(comment.id)}
|
||||
{@const deleting = deletingIds.has(comment.id)}
|
||||
{@const isOwner = isLoggedIn && currentUserId === comment.user_id}
|
||||
|
||||
<div class="rounded-lg bg-zinc-800/50 border border-zinc-700/50 px-4 py-3 flex flex-col gap-2 {deleting ? 'opacity-50' : ''}">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{#if avatarUrls[comment.user_id]}
|
||||
<img src={avatarUrls[comment.user_id]} alt={comment.username} class="w-6 h-6 rounded-full object-cover flex-shrink-0" />
|
||||
{:else}
|
||||
<div class="w-6 h-6 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-[9px] font-semibold text-zinc-300 leading-none">{initials(comment.username)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if comment.username}
|
||||
<a href="/users/{comment.username}" class="text-sm font-medium text-zinc-200 hover:text-amber-400 transition-colors">{comment.username}</a>
|
||||
{:else}
|
||||
<span class="text-sm font-medium text-zinc-400">Anonymous</span>
|
||||
{/if}
|
||||
<span class="text-zinc-600 text-xs">·</span>
|
||||
<span class="text-xs text-zinc-500">{formatDate(comment.created)}</span>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
|
||||
|
||||
<!-- Actions row: votes + reply + delete -->
|
||||
<div class="flex items-center gap-3 pt-1 flex-wrap">
|
||||
<!-- Upvote -->
|
||||
<button
|
||||
onclick={() => vote(comment.id, 'up')}
|
||||
disabled={voting}
|
||||
title="Upvote"
|
||||
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||
{myVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
|
||||
</svg>
|
||||
<span class="tabular-nums">{comment.upvotes ?? 0}</span>
|
||||
</button>
|
||||
|
||||
<!-- Downvote -->
|
||||
<button
|
||||
onclick={() => vote(comment.id, 'down')}
|
||||
disabled={voting}
|
||||
title="Downvote"
|
||||
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||
{myVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
|
||||
</svg>
|
||||
<span class="tabular-nums">{comment.downvotes ?? 0}</span>
|
||||
</button>
|
||||
|
||||
<!-- Reply button -->
|
||||
{#if isLoggedIn}
|
||||
<button
|
||||
onclick={() => {
|
||||
if (replyingTo === comment.id) {
|
||||
replyingTo = null;
|
||||
replyBody = '';
|
||||
replyError = '';
|
||||
} else {
|
||||
replyingTo = comment.id;
|
||||
replyBody = '';
|
||||
replyError = '';
|
||||
}
|
||||
}}
|
||||
class="flex items-center gap-1 text-xs transition-colors
|
||||
{replyingTo === comment.id
|
||||
? 'text-amber-400'
|
||||
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
|
||||
</svg>
|
||||
Reply
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Delete (owner only) -->
|
||||
{#if isOwner}
|
||||
<button
|
||||
onclick={() => deleteComment(comment.id)}
|
||||
disabled={deleting}
|
||||
class="flex items-center gap-1 text-xs text-zinc-600 hover:text-red-400 transition-colors disabled:opacity-50 ml-auto"
|
||||
title="Delete comment"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Inline reply form -->
|
||||
{#if replyingTo === comment.id}
|
||||
<div class="mt-1 flex flex-col gap-2 pl-2 border-l-2 border-zinc-700">
|
||||
<textarea
|
||||
bind:value={replyBody}
|
||||
placeholder="Write a reply…"
|
||||
rows="2"
|
||||
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm placeholder-zinc-500 resize-none focus:outline-none focus:border-amber-400 transition-colors"
|
||||
></textarea>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs {replyCharOver ? 'text-red-400' : 'text-zinc-600'} tabular-nums">
|
||||
{replyCharCount}/2000
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if replyError}
|
||||
<span class="text-xs text-red-400">{replyError}</span>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }}
|
||||
class="px-3 py-1 rounded-lg text-xs text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={() => postReply(comment.id)}
|
||||
disabled={replyPosting || !replyBody.trim() || replyCharOver}
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors
|
||||
{replyPosting || !replyBody.trim() || replyCharOver
|
||||
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
|
||||
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300'}"
|
||||
>
|
||||
{replyPosting ? 'Posting…' : 'Reply'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Replies -->
|
||||
{#if comment.replies && comment.replies.length > 0}
|
||||
<div class="mt-1 flex flex-col gap-2 pl-3 border-l-2 border-zinc-700/60">
|
||||
{#each comment.replies as reply (reply.id)}
|
||||
{@const replyVote = myVotes[reply.id]}
|
||||
{@const replyVoting = votingIds.has(reply.id)}
|
||||
{@const replyDeleting = deletingIds.has(reply.id)}
|
||||
{@const replyIsOwner = isLoggedIn && currentUserId === reply.user_id}
|
||||
|
||||
<div class="rounded-md bg-zinc-800/30 px-3 py-2.5 flex flex-col gap-1.5 {replyDeleting ? 'opacity-50' : ''}">
|
||||
<!-- Reply header -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{#if avatarUrls[reply.user_id]}
|
||||
<img src={avatarUrls[reply.user_id]} alt={reply.username} class="w-5 h-5 rounded-full object-cover flex-shrink-0" />
|
||||
{:else}
|
||||
<div class="w-5 h-5 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-[8px] font-semibold text-zinc-300 leading-none">{initials(reply.username)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if reply.username}
|
||||
<a href="/users/{reply.username}" class="text-xs font-medium text-zinc-300 hover:text-amber-400 transition-colors">{reply.username}</a>
|
||||
{:else}
|
||||
<span class="text-xs font-medium text-zinc-400">Anonymous</span>
|
||||
{/if}
|
||||
<span class="text-zinc-600 text-xs">·</span>
|
||||
<span class="text-xs text-zinc-500">{formatDate(reply.created)}</span>
|
||||
</div>
|
||||
|
||||
<!-- Reply body -->
|
||||
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{reply.body}</p>
|
||||
|
||||
<!-- Reply actions -->
|
||||
<div class="flex items-center gap-3 pt-0.5">
|
||||
<button
|
||||
onclick={() => vote(reply.id, 'up', comment.id)}
|
||||
disabled={replyVoting}
|
||||
title="Upvote"
|
||||
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||
{replyVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
|
||||
</svg>
|
||||
<span class="tabular-nums">{reply.upvotes ?? 0}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => vote(reply.id, 'down', comment.id)}
|
||||
disabled={replyVoting}
|
||||
title="Downvote"
|
||||
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||
{replyVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
|
||||
</svg>
|
||||
<span class="tabular-nums">{reply.downvotes ?? 0}</span>
|
||||
</button>
|
||||
|
||||
{#if replyIsOwner}
|
||||
<button
|
||||
onclick={() => deleteComment(reply.id, comment.id)}
|
||||
disabled={replyDeleting}
|
||||
class="flex items-center gap-1 text-xs text-zinc-600 hover:text-red-400 transition-colors disabled:opacity-50 ml-auto"
|
||||
title="Delete reply"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -15,6 +15,46 @@ const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
// In docker-compose this would differ from the internal endpoint.
|
||||
const MINIO_PUBLIC_URL = pubEnv.PUBLIC_MINIO_PUBLIC_URL ?? 'http://localhost:9000';
|
||||
|
||||
// ─── Avatar helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function extFromMime(mime: string): string {
|
||||
if (mime.includes('png')) return 'png';
|
||||
if (mime.includes('webp')) return 'webp';
|
||||
if (mime.includes('gif')) return 'gif';
|
||||
return 'jpg';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a short-lived presigned PUT URL for uploading an avatar directly to MinIO,
|
||||
* along with the object key to record in PocketBase after upload completes.
|
||||
* Routed through the Go scraper which holds MinIO credentials.
|
||||
*/
|
||||
export async function presignAvatarUploadUrl(userId: string, mimeType: string): Promise<{ uploadUrl: string; key: string }> {
|
||||
const ext = extFromMime(mimeType);
|
||||
const res = await fetch(`${SCRAPER_URL}/api/presign/avatar-upload/${encodeURIComponent(userId)}?ext=${ext}`);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`presign avatar upload failed: ${res.status} ${body}`);
|
||||
}
|
||||
const data = (await res.json()) as { upload_url: string; key: string };
|
||||
return { uploadUrl: data.upload_url, key: data.key };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a presigned GET URL for a user's avatar, rewritten to the public URL.
|
||||
* Returns null if no avatar exists.
|
||||
*/
|
||||
export async function presignAvatarUrl(userId: string): Promise<string | null> {
|
||||
const res = await fetch(`${SCRAPER_URL}/api/presign/avatar/${encodeURIComponent(userId)}`);
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`presign avatar failed: ${res.status} ${body}`);
|
||||
}
|
||||
const data = (await res.json()) as { url: string };
|
||||
return data.url ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the MinIO host in a presigned URL to the public-facing URL.
|
||||
* The presigned URL is signed against the internal endpoint (e.g. minio:9000),
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface User {
|
||||
password_hash: string;
|
||||
role: string;
|
||||
created: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
// ─── Auth token cache ─────────────────────────────────────────────────────────
|
||||
@@ -126,6 +127,14 @@ async function pbPatch(path: string, body: unknown): Promise<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
async function pbDelete(path: string): Promise<Response> {
|
||||
const token = await getToken();
|
||||
return fetch(`${PB_URL}${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
}
|
||||
|
||||
interface PBList<T> {
|
||||
items: T[];
|
||||
totalItems: number;
|
||||
@@ -289,6 +298,38 @@ export async function setProgress(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete progress entry for a specific book (removes from library/continue reading).
|
||||
*/
|
||||
export async function deleteProgress(
|
||||
sessionId: string,
|
||||
slug: string,
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
const existing = await listOne<Progress & { id: string }>(
|
||||
'progress',
|
||||
progressFilter(sessionId, slug, userId)
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
log.debug('pocketbase', 'deleteProgress: no record found', { sessionId, slug, userId });
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await pbDelete(`/api/collections/progress/records/${existing.id}`);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'deleteProgress failed', {
|
||||
slug,
|
||||
id: existing.id,
|
||||
status: res.status,
|
||||
body
|
||||
});
|
||||
throw new Error(`Failed to delete progress: ${res.status}`);
|
||||
}
|
||||
log.info('pocketbase', 'deleteProgress success', { slug, id: existing.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge anonymous session progress into a user account on login/register.
|
||||
*
|
||||
@@ -794,3 +835,515 @@ export async function revokeAllUserSessions(userId: string): Promise<void> {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the avatar_url field for a user record.
|
||||
*/
|
||||
export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Promise<void> {
|
||||
const token = await getToken();
|
||||
const res = await fetch(`${PB_URL}/api/collections/app_users/records/${userId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ avatar_url: avatarUrl })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`updateUserAvatarUrl failed: ${res.status} ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Comments ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BookComment {
|
||||
id: string;
|
||||
slug: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
body: string;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
created: string;
|
||||
parent_id?: string; // empty / absent = top-level; set = reply
|
||||
}
|
||||
|
||||
export interface CommentVote {
|
||||
id: string;
|
||||
comment_id: string;
|
||||
user_id: string;
|
||||
session_id: string;
|
||||
vote: 'up' | 'down';
|
||||
}
|
||||
|
||||
export type CommentSort = 'top' | 'new';
|
||||
|
||||
/**
|
||||
* List top-level comments for a book.
|
||||
* sort='top' → by net score (upvotes − downvotes) desc, then newest
|
||||
* sort='new' → newest first (default)
|
||||
* Replies (parent_id != "") are NOT included — fetch them separately.
|
||||
*/
|
||||
export async function listComments(
|
||||
slug: string,
|
||||
sort: CommentSort = 'new'
|
||||
): Promise<BookComment[]> {
|
||||
const token = await getToken();
|
||||
const slugEsc = slug.replace(/"/g, '\\"');
|
||||
// Only top-level comments (parent_id is empty or missing)
|
||||
const filter = encodeURIComponent(`slug="${slugEsc}"&&(parent_id=""||parent_id=null)`);
|
||||
// PocketBase sorts: for 'top' we still fetch all and re-sort in JS because
|
||||
// PocketBase doesn't support computed sort fields. For 'new' we push the
|
||||
// sort down to the DB so large result sets are still paged correctly.
|
||||
const pbSort = sort === 'new' ? '&sort=-created' : '&sort=-created';
|
||||
const res = await fetch(
|
||||
`${PB_URL}/api/collections/book_comments/records?filter=${filter}${pbSort}&perPage=200`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
let items = (data.items ?? []) as BookComment[];
|
||||
if (sort === 'top') {
|
||||
items = items.sort((a, b) => {
|
||||
const scoreB = (b.upvotes ?? 0) - (b.downvotes ?? 0);
|
||||
const scoreA = (a.upvotes ?? 0) - (a.downvotes ?? 0);
|
||||
if (scoreB !== scoreA) return scoreB - scoreA;
|
||||
// tie-break: newest first
|
||||
return new Date(b.created).getTime() - new Date(a.created).getTime();
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* List replies (1-level deep) for a single parent comment.
|
||||
* Always sorted oldest-first so the conversation reads naturally.
|
||||
*/
|
||||
export async function listReplies(parentId: string): Promise<BookComment[]> {
|
||||
const token = await getToken();
|
||||
const filter = encodeURIComponent(`parent_id="${parentId.replace(/"/g, '\\"')}"`);
|
||||
const res = await fetch(
|
||||
`${PB_URL}/api/collections/book_comments/records?filter=${filter}&sort=created&perPage=100`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return (data.items ?? []) as BookComment[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new comment. Returns the created record.
|
||||
* Pass parentId to create a reply; omit / pass undefined for a top-level comment.
|
||||
*/
|
||||
export async function createComment(
|
||||
slug: string,
|
||||
body: string,
|
||||
userId: string | undefined,
|
||||
username: string,
|
||||
parentId?: string
|
||||
): Promise<BookComment> {
|
||||
const token = await getToken();
|
||||
const res = await fetch(`${PB_URL}/api/collections/book_comments/records`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug,
|
||||
body,
|
||||
user_id: userId ?? '',
|
||||
username,
|
||||
upvotes: 0,
|
||||
downvotes: 0,
|
||||
parent_id: parentId ?? '',
|
||||
created: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`createComment failed: ${res.status} ${text}`);
|
||||
}
|
||||
return res.json() as Promise<BookComment>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment (and optionally its replies) by ID.
|
||||
* Only the comment owner (matched by userId) may delete.
|
||||
* Throws if the comment doesn't exist or the user doesn't own it.
|
||||
*/
|
||||
export async function deleteComment(commentId: string, userId: string): Promise<void> {
|
||||
const token = await getToken();
|
||||
|
||||
// Fetch the comment to verify ownership
|
||||
const getRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!getRes.ok) throw new Error(`Comment not found: ${commentId}`);
|
||||
const comment = (await getRes.json()) as BookComment;
|
||||
if (comment.user_id !== userId) throw new Error('Not authorized to delete this comment');
|
||||
|
||||
// Delete any replies first
|
||||
const repliesFilter = encodeURIComponent(`parent_id="${commentId.replace(/"/g, '\\"')}"`);
|
||||
const repliesRes = await fetch(
|
||||
`${PB_URL}/api/collections/book_comments/records?filter=${repliesFilter}&perPage=100`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
if (repliesRes.ok) {
|
||||
const repliesData = await repliesRes.json();
|
||||
const replies = (repliesData.items ?? []) as BookComment[];
|
||||
await Promise.all(
|
||||
replies.map((r) =>
|
||||
fetch(`${PB_URL}/api/collections/book_comments/records/${r.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Delete the comment itself
|
||||
const delRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!delRes.ok) throw new Error(`deleteComment failed: ${delRes.status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an existing vote by this voter (identified by user_id or session_id) on a comment.
|
||||
*/
|
||||
export async function getCommentVote(
|
||||
commentId: string,
|
||||
sessionId: string,
|
||||
userId?: string
|
||||
): Promise<CommentVote | null> {
|
||||
const token = await getToken();
|
||||
const voterFilter = userId
|
||||
? `comment_id="${commentId}"&&user_id="${userId}"`
|
||||
: `comment_id="${commentId}"&&session_id="${sessionId}"`;
|
||||
const res = await fetch(
|
||||
`${PB_URL}/api/collections/comment_votes/records?filter=${encodeURIComponent(voterFilter)}&perPage=1`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
const items = (data.items ?? []) as CommentVote[];
|
||||
return items[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast or change a vote on a comment. Handles:
|
||||
* - New vote: creates vote record, increments counter.
|
||||
* - Same vote again: removes it (toggle off), decrements counter.
|
||||
* - Changed vote: updates record, adjusts both counters.
|
||||
* Returns the updated comment.
|
||||
*/
|
||||
export async function voteComment(
|
||||
commentId: string,
|
||||
vote: 'up' | 'down',
|
||||
sessionId: string,
|
||||
userId?: string
|
||||
): Promise<BookComment> {
|
||||
const token = await getToken();
|
||||
|
||||
// Fetch current comment
|
||||
const commentRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!commentRes.ok) throw new Error(`Comment not found: ${commentId}`);
|
||||
const comment = (await commentRes.json()) as BookComment;
|
||||
|
||||
const existing = await getCommentVote(commentId, sessionId, userId);
|
||||
|
||||
let upDelta = 0;
|
||||
let downDelta = 0;
|
||||
|
||||
if (!existing) {
|
||||
// New vote
|
||||
await fetch(`${PB_URL}/api/collections/comment_votes/records`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ comment_id: commentId, user_id: userId ?? '', session_id: sessionId, vote })
|
||||
});
|
||||
vote === 'up' ? upDelta++ : downDelta++;
|
||||
} else if (existing.vote === vote) {
|
||||
// Toggle off — remove vote
|
||||
await fetch(`${PB_URL}/api/collections/comment_votes/records/${existing.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
vote === 'up' ? upDelta-- : downDelta--;
|
||||
} else {
|
||||
// Changed vote
|
||||
await fetch(`${PB_URL}/api/collections/comment_votes/records/${existing.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vote })
|
||||
});
|
||||
if (vote === 'up') { upDelta++; downDelta--; }
|
||||
else { upDelta--; downDelta++; }
|
||||
}
|
||||
|
||||
// Patch comment counters
|
||||
const patchRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
upvotes: Math.max(0, (comment.upvotes ?? 0) + upDelta),
|
||||
downvotes: Math.max(0, (comment.downvotes ?? 0) + downDelta)
|
||||
})
|
||||
});
|
||||
if (!patchRes.ok) throw new Error(`Failed to update vote counts on comment ${commentId}`);
|
||||
return patchRes.json() as Promise<BookComment>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch votes cast by this session/user, keyed by comment_id.
|
||||
* Returns a map of commentId → 'up' | 'down'.
|
||||
*/
|
||||
export async function getMyVotes(
|
||||
commentIds: string[],
|
||||
sessionId: string,
|
||||
userId?: string
|
||||
): Promise<Record<string, 'up' | 'down'>> {
|
||||
if (commentIds.length === 0) return {};
|
||||
const token = await getToken();
|
||||
const idFilter = commentIds.map((id) => `comment_id="${id}"`).join('||');
|
||||
const voterPart = userId ? `user_id="${userId}"` : `session_id="${sessionId}"`;
|
||||
const filter = encodeURIComponent(`(${idFilter})&&${voterPart}`);
|
||||
const res = await fetch(
|
||||
`${PB_URL}/api/collections/comment_votes/records?filter=${filter}&perPage=200`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
if (!res.ok) return {};
|
||||
const data = await res.json();
|
||||
const map: Record<string, 'up' | 'down'> = {};
|
||||
for (const v of (data.items ?? []) as CommentVote[]) {
|
||||
map[v.comment_id] = v.vote as 'up' | 'down';
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ─── User subscriptions ───────────────────────────────────────────────────────
|
||||
|
||||
export interface UserSubscription {
|
||||
id: string;
|
||||
follower_id: string;
|
||||
followee_id: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the subscription record if follower_id follows followee_id, else null.
|
||||
*/
|
||||
export async function getSubscription(
|
||||
followerId: string,
|
||||
followeeId: string
|
||||
): Promise<UserSubscription | null> {
|
||||
const filter = encodeURIComponent(`follower_id="${followerId}"&&followee_id="${followeeId}"`);
|
||||
const res = await pbGet<{ items: UserSubscription[]; totalItems: number }>(
|
||||
`/api/collections/user_subscriptions/records?filter=${filter}&perPage=1`
|
||||
).catch(() => null);
|
||||
return res?.items?.[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe follower_id to followee_id. No-ops if already subscribed.
|
||||
* Returns the subscription record.
|
||||
*/
|
||||
export async function subscribe(followerId: string, followeeId: string): Promise<void> {
|
||||
const existing = await getSubscription(followerId, followeeId);
|
||||
if (existing) return;
|
||||
const res = await pbPost('/api/collections/user_subscriptions/records', {
|
||||
follower_id: followerId,
|
||||
followee_id: followeeId,
|
||||
created: new Date().toISOString()
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Failed to subscribe: ${res.status} — ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe follower_id from followee_id. No-ops if not subscribed.
|
||||
*/
|
||||
export async function unsubscribe(followerId: string, followeeId: string): Promise<void> {
|
||||
const existing = await getSubscription(followerId, followeeId);
|
||||
if (!existing) return;
|
||||
const token = await getToken();
|
||||
await fetch(`${PB_URL}/api/collections/user_subscriptions/records/${existing.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of user IDs that followerId is subscribed to.
|
||||
*/
|
||||
export async function getFollowingIds(followerId: string): Promise<string[]> {
|
||||
const items = await listAll<UserSubscription>(
|
||||
'user_subscriptions',
|
||||
`follower_id="${followerId}"`,
|
||||
'-created'
|
||||
).catch(() => [] as UserSubscription[]);
|
||||
return items.map((s) => s.followee_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the count of subscribers (followers) for a given user.
|
||||
*/
|
||||
export async function getFollowerCount(followeeId: string): Promise<number> {
|
||||
return countCollection('user_subscriptions', `followee_id="${followeeId}"`).catch(() => 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the count of accounts a user is following.
|
||||
*/
|
||||
export async function getFollowingCount(followerId: string): Promise<number> {
|
||||
return countCollection('user_subscriptions', `follower_id="${followerId}"`).catch(() => 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public profile data for a user.
|
||||
*/
|
||||
export interface PublicProfile {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar_url?: string;
|
||||
created: string;
|
||||
followerCount: number;
|
||||
followingCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a user's public profile (no sensitive fields) by username.
|
||||
*/
|
||||
export async function getPublicProfile(username: string): Promise<PublicProfile | null> {
|
||||
const user = await getUserByUsername(username);
|
||||
if (!user) return null;
|
||||
const [followerCount, followingCount] = await Promise.all([
|
||||
getFollowerCount(user.id),
|
||||
getFollowingCount(user.id)
|
||||
]);
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatar_url: user.avatar_url,
|
||||
created: user.created,
|
||||
followerCount,
|
||||
followingCount
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a user's public library: books they have saved or are reading.
|
||||
* Only includes books with progress or explicit saves (user_library).
|
||||
*/
|
||||
export async function getUserPublicLibrary(
|
||||
userId: string
|
||||
): Promise<Array<{ book: Book; chapter: number | null; saved: boolean }>> {
|
||||
const [allBooks, progressList, savedEntries] = await Promise.all([
|
||||
listBooks(),
|
||||
listAll<Progress>('progress', `user_id="${userId}"`, '-updated').catch(() => [] as Progress[]),
|
||||
listAll<{ id: string; slug: string; saved_at: string }>(
|
||||
'user_library',
|
||||
`user_id="${userId}"`,
|
||||
'-saved_at'
|
||||
).catch(() => [] as { id: string; slug: string; saved_at: string }[])
|
||||
]);
|
||||
|
||||
const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));
|
||||
const result: Array<{ book: Book; chapter: number | null; saved: boolean }> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Books with progress first (most recently read)
|
||||
for (const p of progressList) {
|
||||
const book = bookMap.get(p.slug);
|
||||
if (!book || seen.has(p.slug)) continue;
|
||||
seen.add(p.slug);
|
||||
result.push({ book, chapter: p.chapter, saved: false });
|
||||
}
|
||||
|
||||
// Saved-only books next
|
||||
for (const e of savedEntries) {
|
||||
const book = bookMap.get(e.slug);
|
||||
if (!book || seen.has(e.slug)) continue;
|
||||
seen.add(e.slug);
|
||||
result.push({ book, chapter: null, saved: true });
|
||||
}
|
||||
|
||||
// Mark saved flag for books that are both in progress AND saved
|
||||
const savedSlugs = new Set(savedEntries.map((e) => e.slug));
|
||||
return result.map((r) => ({ ...r, saved: savedSlugs.has(r.book.slug) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently-reading books (books with progress, not completed)
|
||||
* for a given user ID.
|
||||
*/
|
||||
export async function getUserCurrentlyReading(
|
||||
userId: string
|
||||
): Promise<Array<{ book: Book; chapter: number }>> {
|
||||
const [allBooks, progressList] = await Promise.all([
|
||||
listBooks(),
|
||||
listAll<Progress>('progress', `user_id="${userId}"`, '-updated').catch(() => [] as Progress[])
|
||||
]);
|
||||
const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));
|
||||
return progressList
|
||||
.filter((p) => {
|
||||
const book = bookMap.get(p.slug);
|
||||
return book && p.chapter > 0 && p.chapter < book.total_chapters;
|
||||
})
|
||||
.slice(0, 10)
|
||||
.map((p) => ({ book: bookMap.get(p.slug)!, chapter: p.chapter }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns recently-updated books from ALL users that followerId is subscribed to.
|
||||
* Deduplicates across followed users; sorts by most recently updated.
|
||||
*/
|
||||
export async function getSubscriptionFeed(
|
||||
followerId: string,
|
||||
limit = 12
|
||||
): Promise<Array<{ book: Book; readerUsername: string }>> {
|
||||
const followingIds = await getFollowingIds(followerId);
|
||||
if (followingIds.length === 0) return [];
|
||||
|
||||
// Fetch all users we follow (for display names)
|
||||
const token = await getToken();
|
||||
const userFetches = followingIds.map((id) =>
|
||||
fetch(`${PB_URL}/api/collections/app_users/records/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
.then((r) => (r.ok ? (r.json() as Promise<User>) : null))
|
||||
.catch(() => null)
|
||||
);
|
||||
const users = (await Promise.all(userFetches)).filter(Boolean) as User[];
|
||||
const userMap = new Map<string, User>(users.map((u) => [u.id, u]));
|
||||
|
||||
// Fetch progress for each followed user
|
||||
const progressFetches = followingIds.map((id) =>
|
||||
listAll<Progress>('progress', `user_id="${id}"`, '-updated').catch(() => [] as Progress[])
|
||||
);
|
||||
const allProgressArrays = await Promise.all(progressFetches);
|
||||
|
||||
const allBooks = await listBooks();
|
||||
const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));
|
||||
|
||||
// Merge: per slug take the most-recent progress entry
|
||||
const seen = new Set<string>();
|
||||
const feed: Array<{ book: Book; readerUsername: string; updated: string }> = [];
|
||||
|
||||
for (let i = 0; i < followingIds.length; i++) {
|
||||
const uid = followingIds[i];
|
||||
const username = userMap.get(uid)?.username ?? 'unknown';
|
||||
for (const p of allProgressArrays[i]) {
|
||||
if (seen.has(p.slug)) continue;
|
||||
const book = bookMap.get(p.slug);
|
||||
if (!book) continue;
|
||||
seen.add(p.slug);
|
||||
feed.push({ book, readerUsername: username, updated: p.updated });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by most recently read across all followed users
|
||||
feed.sort((a, b) => b.updated.localeCompare(a.updated));
|
||||
return feed.slice(0, limit).map(({ book, readerUsername }) => ({ book, readerUsername }));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
let { children, data }: { children: Snippet; data: LayoutData } = $props();
|
||||
|
||||
@@ -395,6 +396,9 @@
|
||||
<a href="/privacy" class="hover:text-zinc-500 transition-colors">Privacy</a>
|
||||
<a href="/dmca" class="hover:text-zinc-500 transition-colors">DMCA</a>
|
||||
<span>© {new Date().getFullYear()} libnovel</span>
|
||||
{#if env.PUBLIC_BUILD_VERSION && env.PUBLIC_BUILD_VERSION !== 'dev'}
|
||||
<span class="text-zinc-800">{env.PUBLIC_BUILD_VERSION}+{env.PUBLIC_BUILD_COMMIT?.slice(0, 7)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
listBooks,
|
||||
recentlyAddedBooks,
|
||||
allProgress,
|
||||
getHomeStats
|
||||
getHomeStats,
|
||||
getSubscriptionFeed
|
||||
} from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
import type { Book, Progress } from '$lib/server/pocketbase';
|
||||
@@ -38,9 +39,18 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug));
|
||||
const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6);
|
||||
|
||||
// Subscription feed — only when logged in
|
||||
const subscriptionFeed = locals.user
|
||||
? await getSubscriptionFeed(locals.user.id, 12).catch((e) => {
|
||||
log.error('home', 'failed to load subscription feed', { err: String(e) });
|
||||
return [] as Awaited<ReturnType<typeof getSubscriptionFeed>>;
|
||||
})
|
||||
: [];
|
||||
|
||||
return {
|
||||
continueReading,
|
||||
recentlyUpdated,
|
||||
subscriptionFeed,
|
||||
stats: {
|
||||
...stats,
|
||||
booksInProgress: continueReading.length
|
||||
|
||||
@@ -147,3 +147,56 @@
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- From Subscriptions -->
|
||||
{#if data.subscriptionFeed.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-lg font-bold text-zinc-100">From People You Follow</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each data.subscriptionFeed as { book, readerUsername }}
|
||||
{@const genres = parseGenres(book.genres)}
|
||||
<a
|
||||
href="/books/{book.slug}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
|
||||
>
|
||||
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden">
|
||||
{#if book.cover}
|
||||
<img
|
||||
src={book.cover}
|
||||
alt={book.title}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-zinc-600">
|
||||
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="p-2 flex flex-col gap-1">
|
||||
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
{#if book.author}
|
||||
<p class="text-xs text-zinc-400 truncate">{book.author}</p>
|
||||
{/if}
|
||||
<!-- Reader attribution -->
|
||||
<p class="text-xs text-zinc-600 truncate mt-0.5">
|
||||
via <span class="text-amber-500/70">{readerUsername}</span>
|
||||
</p>
|
||||
{#if genres.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-auto pt-1">
|
||||
{#each genres.slice(0, 1) as genre}
|
||||
<span class="text-xs px-1 py-0.5 rounded bg-zinc-900 text-zinc-500">{genre}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getUserByUsername } from '$lib/server/pocketbase';
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
@@ -10,10 +11,12 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.user) {
|
||||
error(401, 'Not authenticated');
|
||||
}
|
||||
// Fetch full record from PocketBase to get avatar_url
|
||||
const record = await getUserByUsername(locals.user.username).catch(() => null);
|
||||
return json({
|
||||
id: locals.user.id,
|
||||
username: locals.user.username,
|
||||
role: locals.user.role,
|
||||
created: locals.user.created ?? ''
|
||||
avatar_url: record?.avatar_url ?? null
|
||||
});
|
||||
};
|
||||
|
||||
@@ -104,7 +104,7 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||
const res = await fetch(presignUrl);
|
||||
if (!res.ok) throw new Error(`MinIO returned ${res.status}`);
|
||||
const markdown = await res.text();
|
||||
html = await marked(markdown, { async: true });
|
||||
html = marked(markdown) as string;
|
||||
} catch (e) {
|
||||
log.error('api/chapter', 'failed to fetch chapter content', { slug, n, err: String(e) });
|
||||
}
|
||||
|
||||
26
ui/src/routes/api/comment/[id]/+server.ts
Normal file
26
ui/src/routes/api/comment/[id]/+server.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { deleteComment } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* DELETE /api/comment/[id]
|
||||
* Deletes a comment and its replies. Only the comment owner may delete.
|
||||
* Requires authentication.
|
||||
*/
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user) error(401, 'Login required');
|
||||
|
||||
const { id } = params;
|
||||
|
||||
try {
|
||||
await deleteComment(id, locals.user.id);
|
||||
return new Response(null, { status: 204 });
|
||||
} catch (e) {
|
||||
const msg = String(e);
|
||||
if (msg.includes('Not authorized')) error(403, 'Not authorized to delete this comment');
|
||||
if (msg.includes('not found')) error(404, 'Comment not found');
|
||||
log.error('api/comment/[id]', 'deleteComment failed', { id, err: msg });
|
||||
error(500, 'Failed to delete comment');
|
||||
}
|
||||
};
|
||||
33
ui/src/routes/api/comment/[id]/vote/+server.ts
Normal file
33
ui/src/routes/api/comment/[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/comment/[id]/vote
|
||||
* Body: { vote: 'up' | 'down' }
|
||||
* Casts, changes, or toggles off a vote on a comment.
|
||||
* Works for both authenticated and anonymous users (session-scoped).
|
||||
* Returns the updated comment.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
const { id } = params;
|
||||
let body: { vote?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
error(400, 'Invalid JSON body');
|
||||
}
|
||||
|
||||
if (body.vote !== 'up' && body.vote !== 'down') {
|
||||
error(400, 'vote must be "up" or "down"');
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await voteComment(id, body.vote, locals.sessionId, locals.user?.id);
|
||||
return json(updated);
|
||||
} catch (e) {
|
||||
log.error('api/comment/[id]/vote', 'voteComment failed', { id, err: String(e) });
|
||||
error(500, 'Failed to record vote');
|
||||
}
|
||||
};
|
||||
102
ui/src/routes/api/comments/[slug]/+server.ts
Normal file
102
ui/src/routes/api/comments/[slug]/+server.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import {
|
||||
listComments,
|
||||
listReplies,
|
||||
createComment,
|
||||
getMyVotes,
|
||||
type CommentSort
|
||||
} from '$lib/server/pocketbase';
|
||||
import { presignAvatarUrl } from '$lib/server/minio';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* GET /api/comments/[slug]?sort=new|top
|
||||
* Returns top-level comments + their replies + current visitor's votes + avatar URLs.
|
||||
* Response: { comments: BookComment[], myVotes: Record<string, 'up'|'down'>, avatarUrls: Record<string, string> }
|
||||
* Each top-level comment has a `replies` array attached.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||
const { slug } = params;
|
||||
const sortParam = url.searchParams.get('sort') ?? 'new';
|
||||
const sort: CommentSort = sortParam === 'top' ? 'top' : 'new';
|
||||
|
||||
try {
|
||||
const topLevel = await listComments(slug, sort);
|
||||
|
||||
// Fetch replies for all top-level comments in parallel
|
||||
const repliesPerComment = await Promise.all(topLevel.map((c) => listReplies(c.id)));
|
||||
const allReplies = repliesPerComment.flat();
|
||||
|
||||
// Build comment+reply list for vote lookup
|
||||
const allIds = [...topLevel.map((c) => c.id), ...allReplies.map((r) => r.id)];
|
||||
const myVotes = await getMyVotes(allIds, locals.sessionId, locals.user?.id);
|
||||
|
||||
// Attach replies to each top-level comment
|
||||
const comments = topLevel.map((c, i) => ({
|
||||
...c,
|
||||
replies: repliesPerComment[i]
|
||||
}));
|
||||
|
||||
// Batch-resolve avatar presign URLs for all unique user_ids
|
||||
const allComments = [...topLevel, ...allReplies];
|
||||
const uniqueUserIds = [...new Set(allComments.map((c) => c.user_id).filter(Boolean))];
|
||||
const avatarEntries = await Promise.all(
|
||||
uniqueUserIds.map(async (userId) => {
|
||||
try {
|
||||
const url = await presignAvatarUrl(userId);
|
||||
return [userId, url] as [string, string | null];
|
||||
} catch {
|
||||
return [userId, null] as [string, null];
|
||||
}
|
||||
})
|
||||
);
|
||||
const avatarUrls: Record<string, string> = {};
|
||||
for (const [userId, url] of avatarEntries) {
|
||||
if (url) avatarUrls[userId] = url;
|
||||
}
|
||||
|
||||
return json({ comments, myVotes, avatarUrls });
|
||||
} catch (e) {
|
||||
log.error('api/comments/[slug]', 'listComments failed', { slug, err: String(e) });
|
||||
error(500, 'Failed to load comments');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/comments/[slug]
|
||||
* Body: { body: string, parent_id?: string }
|
||||
* Creates a new comment or reply. Requires authentication.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
if (!locals.user) error(401, 'Login required to comment');
|
||||
|
||||
const { slug } = params;
|
||||
let body: { body?: string; parent_id?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
error(400, 'Invalid JSON body');
|
||||
}
|
||||
|
||||
const text = (body.body ?? '').trim();
|
||||
if (!text) error(400, 'Comment body is required');
|
||||
if (text.length > 2000) error(400, 'Comment is too long (max 2000 characters)');
|
||||
|
||||
// Enforce 1-level depth: parent_id must be a top-level comment
|
||||
const parentId = body.parent_id?.trim() || undefined;
|
||||
|
||||
try {
|
||||
const comment = await createComment(
|
||||
slug,
|
||||
text,
|
||||
locals.user.id,
|
||||
locals.user.username,
|
||||
parentId
|
||||
);
|
||||
return json(comment, { status: 201 });
|
||||
} catch (e) {
|
||||
log.error('api/comments/[slug]', 'createComment failed', { slug, err: String(e) });
|
||||
error(500, 'Failed to post comment');
|
||||
}
|
||||
};
|
||||
@@ -1,12 +1,19 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { listBooks, recentlyAddedBooks, allProgress, getHomeStats } from '$lib/server/pocketbase';
|
||||
import {
|
||||
listBooks,
|
||||
recentlyAddedBooks,
|
||||
allProgress,
|
||||
getHomeStats,
|
||||
getSubscriptionFeed
|
||||
} from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
import type { Book, Progress } from '$lib/server/pocketbase';
|
||||
|
||||
/**
|
||||
* GET /api/home
|
||||
* Returns home screen data: continue-reading list, recently updated books, and stats.
|
||||
* Returns home screen data: continue-reading list, recently updated books, stats,
|
||||
* and subscription feed (books recently read by followed users).
|
||||
* Requires authentication (enforced by layout guard).
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
@@ -36,6 +43,12 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug));
|
||||
const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6);
|
||||
|
||||
// Subscription feed — only available for logged-in users with following
|
||||
let subscriptionFeed: Array<{ book: Book; readerUsername: string }> = [];
|
||||
if (locals.user?.id) {
|
||||
subscriptionFeed = await getSubscriptionFeed(locals.user.id).catch(() => []);
|
||||
}
|
||||
|
||||
return json({
|
||||
continue_reading: continueReading,
|
||||
recently_updated: recentlyUpdated,
|
||||
@@ -43,6 +56,10 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
totalBooks: stats.totalBooks,
|
||||
totalChapters: stats.totalChapters,
|
||||
booksInProgress: continueReading.length
|
||||
}
|
||||
},
|
||||
subscription_feed: subscriptionFeed.map((item) => ({
|
||||
book: item.book,
|
||||
readerUsername: item.readerUsername
|
||||
}))
|
||||
});
|
||||
};
|
||||
|
||||
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 });
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { setProgress } from '$lib/server/pocketbase';
|
||||
import { setProgress, deleteProgress } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
@@ -32,3 +32,23 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/progress/[slug]
|
||||
* Removes reading progress for a specific book (removes from library/continue reading).
|
||||
*/
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
const { slug } = params;
|
||||
|
||||
try {
|
||||
await deleteProgress(locals.sessionId, slug, locals.user?.id);
|
||||
} catch (e) {
|
||||
log.error('api/progress/[slug]', 'deleteProgress failed', {
|
||||
slug,
|
||||
err: String(e)
|
||||
});
|
||||
error(500, 'Failed to delete progress');
|
||||
}
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
46
ui/src/routes/api/users/[username]/+server.ts
Normal file
46
ui/src/routes/api/users/[username]/+server.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getPublicProfile, getSubscription } from '$lib/server/pocketbase';
|
||||
import { presignAvatarUrl } from '$lib/server/minio';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* GET /api/users/[username]
|
||||
* Returns public profile info + whether the current user is subscribed.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
const { username } = params;
|
||||
|
||||
try {
|
||||
const profile = await getPublicProfile(username);
|
||||
if (!profile) error(404, `User "${username}" not found`);
|
||||
|
||||
// Resolve avatar presigned URL if set
|
||||
let avatarUrl: string | null = null;
|
||||
if (profile.avatar_url) {
|
||||
avatarUrl = await presignAvatarUrl(profile.id).catch(() => null);
|
||||
}
|
||||
|
||||
// Is the current logged-in user subscribed?
|
||||
let isSubscribed = false;
|
||||
if (locals.user && locals.user.id !== profile.id) {
|
||||
const sub = await getSubscription(locals.user.id, profile.id).catch(() => null);
|
||||
isSubscribed = !!sub;
|
||||
}
|
||||
|
||||
return json({
|
||||
id: profile.id,
|
||||
username: profile.username,
|
||||
avatarUrl,
|
||||
created: profile.created,
|
||||
followerCount: profile.followerCount,
|
||||
followingCount: profile.followingCount,
|
||||
isSubscribed,
|
||||
isSelf: locals.user?.id === profile.id
|
||||
});
|
||||
} catch (e) {
|
||||
if ((e as { status?: number }).status === 404) throw e;
|
||||
log.error('api/users', 'failed to load profile', { username, err: String(e) });
|
||||
error(500, 'Failed to load profile');
|
||||
}
|
||||
};
|
||||
43
ui/src/routes/api/users/[username]/library/+server.ts
Normal file
43
ui/src/routes/api/users/[username]/library/+server.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import {
|
||||
getUserByUsername,
|
||||
getUserPublicLibrary,
|
||||
getUserCurrentlyReading
|
||||
} from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* GET /api/users/[username]/library
|
||||
* Returns the public library + currently-reading list for a user.
|
||||
* Does not require authentication — all data is public.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const { username } = params;
|
||||
|
||||
const user = await getUserByUsername(username).catch(() => null);
|
||||
if (!user) error(404, `User "${username}" not found`);
|
||||
|
||||
try {
|
||||
const [currentlyReading, library] = await Promise.all([
|
||||
getUserCurrentlyReading(user.id),
|
||||
getUserPublicLibrary(user.id)
|
||||
]);
|
||||
|
||||
return json({
|
||||
currently_reading: currentlyReading.map((item) => ({
|
||||
book: item.book,
|
||||
last_chapter: item.chapter,
|
||||
saved: false
|
||||
})),
|
||||
library: library.map((item) => ({
|
||||
book: item.book,
|
||||
last_chapter: item.chapter,
|
||||
saved: item.saved
|
||||
}))
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('api/users/library', 'failed to load library', { username, err: String(e) });
|
||||
error(500, 'Failed to load library');
|
||||
}
|
||||
};
|
||||
48
ui/src/routes/api/users/[username]/subscribe/+server.ts
Normal file
48
ui/src/routes/api/users/[username]/subscribe/+server.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import {
|
||||
getUserByUsername,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
getSubscription
|
||||
} from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* POST /api/users/[username]/subscribe — subscribe to a user
|
||||
* DELETE /api/users/[username]/subscribe — unsubscribe
|
||||
* Requires authentication.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user) error(401, 'Login required');
|
||||
|
||||
const { username } = params;
|
||||
const target = await getUserByUsername(username).catch(() => null);
|
||||
if (!target) error(404, `User "${username}" not found`);
|
||||
if (locals.user.id === target.id) error(400, 'Cannot subscribe to yourself');
|
||||
|
||||
try {
|
||||
await subscribe(locals.user.id, target.id);
|
||||
const sub = await getSubscription(locals.user.id, target.id);
|
||||
return json({ subscribed: true, subId: sub?.id ?? null });
|
||||
} catch (e) {
|
||||
log.error('api/users/subscribe', 'subscribe failed', { username, err: String(e) });
|
||||
error(500, 'Failed to subscribe');
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user) error(401, 'Login required');
|
||||
|
||||
const { username } = params;
|
||||
const target = await getUserByUsername(username).catch(() => null);
|
||||
if (!target) error(404, `User "${username}" not found`);
|
||||
|
||||
try {
|
||||
await unsubscribe(locals.user.id, target.id);
|
||||
return json({ subscribed: false });
|
||||
} catch (e) {
|
||||
log.error('api/users/subscribe', 'unsubscribe failed', { username, err: String(e) });
|
||||
error(500, 'Failed to unsubscribe');
|
||||
}
|
||||
};
|
||||
@@ -43,7 +43,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
inLib: true,
|
||||
saved,
|
||||
lastChapter: progress?.chapter ?? null,
|
||||
isAdmin: locals.user?.role === 'admin'
|
||||
isAdmin: locals.user?.role === 'admin',
|
||||
isLoggedIn: !!locals.user,
|
||||
currentUserId: locals.user?.id ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,7 +95,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
inLib: preview.in_lib,
|
||||
saved: false,
|
||||
lastChapter: null,
|
||||
isAdmin: locals.user?.role === 'admin'
|
||||
isAdmin: locals.user?.role === 'admin',
|
||||
isLoggedIn: !!locals.user,
|
||||
currentUserId: locals.user?.id ?? ''
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
import CommentsSection from '$lib/components/CommentsSection.svelte';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -32,57 +31,12 @@
|
||||
|
||||
const genres = $derived(parseGenres(data.book.genres));
|
||||
|
||||
// Paginate chapter list — 50 on mobile, 100 on sm+ (≥640px)
|
||||
let pageSize = $state(50);
|
||||
|
||||
onMount(() => {
|
||||
const mq = window.matchMedia('(min-width: 640px)');
|
||||
pageSize = mq.matches ? 100 : 50;
|
||||
const handler = (e: MediaQueryListEvent) => { pageSize = e.matches ? 100 : 50; };
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
});
|
||||
|
||||
// Start on the page that contains the current chapter (if any)
|
||||
function pageForChapter(chapterNum: number | null, list: typeof chapterList): number {
|
||||
if (!chapterNum || list.length === 0) return 0;
|
||||
const idx = list.findIndex((c) => c.number === chapterNum);
|
||||
if (idx === -1) return 0;
|
||||
return Math.floor(idx / pageSize);
|
||||
}
|
||||
|
||||
let page = $state(pageForChapter(data.lastChapter, data.inLib ? data.chapters : (data.previewChapters ?? [])));
|
||||
|
||||
// Use preview chapters if the book is not in the library
|
||||
// Use preview chapters if the book is not in the library (needed for chapter count)
|
||||
const chapterList = $derived(
|
||||
data.inLib
|
||||
? data.chapters
|
||||
: (data.previewChapters ?? [])
|
||||
);
|
||||
const totalPages = $derived(Math.ceil(chapterList.length / pageSize));
|
||||
const visibleChapters = $derived(
|
||||
chapterList.slice(page * pageSize, (page + 1) * pageSize)
|
||||
);
|
||||
|
||||
// ── Chapter list polling ──────────────────────────────────────────────────
|
||||
// When the book was just added to the library via preview (inLib=true but
|
||||
// no chapters yet), poll until the background WriteChapterRefs completes.
|
||||
let pollingChapters = $state(data.inLib && data.chapters.length === 0);
|
||||
|
||||
onMount(() => {
|
||||
if (!pollingChapters) return;
|
||||
let attempts = 0;
|
||||
const MAX_ATTEMPTS = 20; // ~10 seconds
|
||||
const timer = setInterval(async () => {
|
||||
attempts++;
|
||||
await invalidateAll();
|
||||
if (data.chapters.length > 0 || attempts >= MAX_ATTEMPTS) {
|
||||
pollingChapters = false;
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, 500);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
|
||||
// ── Admin: rescrape ───────────────────────────────────────────────────────
|
||||
let scraping = $state(false);
|
||||
@@ -137,26 +91,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function scrapeFromChapter(n: number) {
|
||||
if (rangeScraping || !data.book.source_url) return;
|
||||
rangeScraping = true;
|
||||
rangeResult = '';
|
||||
try {
|
||||
const res = await fetch('/api/scrape/range', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: data.book.source_url, from: n })
|
||||
});
|
||||
if (res.ok) rangeResult = 'queued';
|
||||
else if (res.status === 409) rangeResult = 'busy';
|
||||
else rangeResult = 'error';
|
||||
} catch {
|
||||
rangeResult = 'error';
|
||||
} finally {
|
||||
rangeScraping = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary expand/collapse ───────────────────────────────────────────────
|
||||
let summaryExpanded = $state(false);
|
||||
|
||||
@@ -341,101 +275,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════ Chapter list ══ -->
|
||||
<div>
|
||||
<!-- Header row: title + pagination -->
|
||||
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||
<h2 class="text-base font-semibold text-zinc-200">
|
||||
Chapters
|
||||
<!-- ══════════════════════════════════════════════════ Chapters row ══ -->
|
||||
<div class="flex flex-col divide-y divide-zinc-800 border border-zinc-800 rounded-xl overflow-hidden mb-6">
|
||||
<!-- Chapters row: links to the full chapter list page -->
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters"
|
||||
class="flex items-center gap-3 px-4 py-3.5 hover:bg-zinc-800/60 transition-colors group"
|
||||
>
|
||||
<svg class="w-4 h-4 text-amber-400 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h10"/>
|
||||
</svg>
|
||||
<div class="flex flex-col min-w-0 flex-1">
|
||||
<span class="text-sm font-semibold text-zinc-200">Chapters</span>
|
||||
{#if chapterList.length > 0}
|
||||
<span class="text-zinc-500 font-normal text-sm ml-1">({chapterList.length})</span>
|
||||
{/if}
|
||||
</h2>
|
||||
|
||||
{#if totalPages > 1}
|
||||
<div class="flex gap-2 items-center text-sm">
|
||||
<button
|
||||
onclick={() => (page = Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
class="px-2 py-1 rounded bg-zinc-800 text-zinc-300 disabled:opacity-40 hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<span class="text-zinc-500 text-xs tabular-nums">
|
||||
{page * pageSize + 1}–{Math.min((page + 1) * pageSize, chapterList.length)} of {chapterList.length}
|
||||
</span>
|
||||
<button
|
||||
onclick={() => (page = Math.min(totalPages - 1, page + 1))}
|
||||
disabled={page === totalPages - 1}
|
||||
class="px-2 py-1 rounded bg-zinc-800 text-zinc-300 disabled:opacity-40 hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Chapter rows -->
|
||||
{#if pollingChapters}
|
||||
<div class="flex items-center gap-3 py-4 text-zinc-500 text-sm">
|
||||
<svg class="w-4 h-4 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Indexing chapter list…
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 opacity-40 pointer-events-none">
|
||||
{#each Array(8) as _}
|
||||
<div class="h-9 rounded bg-zinc-800 animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if chapterList.length === 0}
|
||||
<p class="text-zinc-500 text-sm">No chapters available yet.</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each visibleChapters as chapter}
|
||||
{@const isCurrent = data.lastChapter === chapter.number}
|
||||
{@const chapterUrl = data.inLib
|
||||
? `/books/${data.book.slug}/chapters/${chapter.number}`
|
||||
: `/books/${data.book.slug}/chapters/${chapter.number}?preview=1&chapter_url=${encodeURIComponent((chapter as { url?: string }).url ?? '')}&title=${encodeURIComponent(chapter.title ?? '')}`}
|
||||
<div class="flex items-center gap-2 px-3 py-2.5 rounded hover:bg-zinc-800/70 transition-colors group {isCurrent ? 'bg-zinc-800' : ''}">
|
||||
<a href={chapterUrl} class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<!-- Chapter number -->
|
||||
<span class="text-sm font-mono w-10 text-right flex-shrink-0 {isCurrent ? 'text-amber-400' : 'text-zinc-600'}">
|
||||
{chapter.number}
|
||||
</span>
|
||||
<!-- Title -->
|
||||
<span class="text-base {isCurrent ? 'text-amber-300' : 'text-zinc-300 group-hover:text-zinc-100'} truncate min-w-0 flex-1 transition-colors">
|
||||
{chapter.title || `Chapter ${chapter.number}`}
|
||||
</span>
|
||||
<!-- Date label — desktop only -->
|
||||
{#if (chapter as { date_label?: string }).date_label}
|
||||
<span class="text-sm text-zinc-600 flex-shrink-0 max-sm:hidden">· {(chapter as { date_label?: string }).date_label}</span>
|
||||
{/if}
|
||||
<!-- "reading" badge -->
|
||||
{#if isCurrent}
|
||||
<span class="text-sm text-amber-500 flex-shrink-0 font-medium">reading</span>
|
||||
{/if}
|
||||
</a>
|
||||
<!-- Admin: scrape from this chapter up (hover-only) -->
|
||||
{#if data.isAdmin && data.book.source_url && data.inLib}
|
||||
<button
|
||||
onclick={() => scrapeFromChapter(chapter.number)}
|
||||
disabled={rangeScraping}
|
||||
class="opacity-0 group-hover:opacity-100 shrink-0 text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-500 hover:bg-amber-500/30 transition-all border border-amber-500/20 disabled:opacity-30"
|
||||
title="Scrape from chapter {chapter.number} up"
|
||||
>
|
||||
↑ here
|
||||
</button>
|
||||
<span class="text-xs text-zinc-500">
|
||||
{#if data.lastChapter && data.lastChapter > 0}
|
||||
Reading ch.{data.lastChapter} of {chapterList.length}
|
||||
{:else}
|
||||
{chapterList.length} chapter{chapterList.length === 1 ? '' : 's'}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<svg class="w-4 h-4 text-zinc-600 group-hover:text-zinc-400 transition-colors flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- ── Admin panel (collapsed by default) ── -->
|
||||
<!-- Admin panel (collapsed by default, admin only) -->
|
||||
{#if data.isAdmin && data.book.source_url}
|
||||
<div class="mt-6 border border-zinc-800 rounded-lg overflow-hidden">
|
||||
<div>
|
||||
<button
|
||||
onclick={() => (adminOpen = !adminOpen)}
|
||||
class="w-full flex items-center gap-2 px-4 py-2.5 text-xs font-medium text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/50 transition-colors text-left"
|
||||
@@ -525,3 +394,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════ Comments ══ -->
|
||||
<CommentsSection slug={data.book.slug} isLoggedIn={data.isLoggedIn} currentUserId={data.currentUserId} />
|
||||
|
||||
32
ui/src/routes/books/[slug]/chapters/+page.server.ts
Normal file
32
ui/src/routes/books/[slug]/chapters/+page.server.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getBook, listChapterIdx, getProgress } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
const { slug } = params;
|
||||
|
||||
const book = await getBook(slug).catch((e) => {
|
||||
log.error('chapters', 'getBook failed', { slug, err: String(e) });
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!book) error(404, `Book "${slug}" not found`);
|
||||
|
||||
let chapters, progress;
|
||||
try {
|
||||
[chapters, progress] = await Promise.all([
|
||||
listChapterIdx(slug),
|
||||
getProgress(locals.sessionId, slug, locals.user?.id)
|
||||
]);
|
||||
} catch (e) {
|
||||
log.error('chapters', 'failed to load chapters', { slug, err: String(e) });
|
||||
throw error(500, 'Failed to load chapters');
|
||||
}
|
||||
|
||||
return {
|
||||
book: { slug: book.slug, title: book.title, cover: book.cover ?? '', totalChapters: book.total_chapters },
|
||||
chapters,
|
||||
lastChapter: progress?.chapter ?? null
|
||||
};
|
||||
};
|
||||
203
ui/src/routes/books/[slug]/chapters/+page.svelte
Normal file
203
ui/src/routes/books/[slug]/chapters/+page.svelte
Normal file
@@ -0,0 +1,203 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import type { ChapterIdx } from '$lib/server/pocketbase';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
// ── Search ──────────────────────────────────────────────────────────────────
|
||||
let searchQuery = $state('');
|
||||
|
||||
const filtered = $derived(
|
||||
(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (!q) return data.chapters;
|
||||
return data.chapters.filter(
|
||||
(c: ChapterIdx) =>
|
||||
String(c.number).includes(q) ||
|
||||
c.title.toLowerCase().includes(q)
|
||||
);
|
||||
})()
|
||||
);
|
||||
|
||||
// ── Page groups (only shown when not searching) ──────────────────────────
|
||||
const totalGroups = $derived(Math.ceil(data.chapters.length / PAGE_SIZE));
|
||||
|
||||
// Which group the current chapter is in (0-indexed)
|
||||
const currentGroup = $derived(
|
||||
data.lastChapter
|
||||
? Math.floor(
|
||||
(data.chapters.findIndex((c: ChapterIdx) => c.number === data.lastChapter)) /
|
||||
PAGE_SIZE
|
||||
)
|
||||
: 0
|
||||
);
|
||||
|
||||
let activeGroup = $state(0);
|
||||
|
||||
// On mount, jump to the group containing the current chapter
|
||||
$effect(() => {
|
||||
if (data.lastChapter && currentGroup >= 0) {
|
||||
activeGroup = currentGroup;
|
||||
}
|
||||
});
|
||||
|
||||
const visibleChapters = $derived(
|
||||
searchQuery.trim()
|
||||
? filtered
|
||||
: data.chapters.slice(activeGroup * PAGE_SIZE, (activeGroup + 1) * PAGE_SIZE)
|
||||
);
|
||||
|
||||
function groupLabel(i: number): string {
|
||||
const from = i * PAGE_SIZE + 1;
|
||||
const to = Math.min((i + 1) * PAGE_SIZE, data.chapters.length);
|
||||
return `${from}–${to}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.book.title} — Chapters — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- ── Back link + title ─────────────────────────────────────────────────── -->
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<a
|
||||
href="/books/{data.book.slug}"
|
||||
class="flex items-center gap-1.5 text-zinc-400 hover:text-zinc-200 transition-colors text-sm"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Back
|
||||
</a>
|
||||
<span class="text-zinc-700">/</span>
|
||||
<h1 class="text-base font-semibold text-zinc-200 truncate">{data.book.title}</h1>
|
||||
</div>
|
||||
|
||||
<!-- ── Search bar ───────────────────────────────────────────────────────── -->
|
||||
<div class="relative mb-4">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="8"/><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35"/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search chapters…"
|
||||
bind:value={searchQuery}
|
||||
class="w-full pl-9 pr-4 py-2.5 rounded-lg bg-zinc-800 border border-zinc-700 text-zinc-200 placeholder-zinc-500 text-sm focus:outline-none focus:border-amber-400 transition-colors"
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
onclick={() => (searchQuery = '')}
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Page-group selector (hidden while searching) ──────────────────────── -->
|
||||
{#if !searchQuery && totalGroups > 1}
|
||||
<div class="flex flex-wrap gap-1.5 mb-4">
|
||||
{#each Array(totalGroups) as _, i}
|
||||
<button
|
||||
onclick={() => (activeGroup = i)}
|
||||
class="px-2.5 py-1 rounded text-xs font-medium transition-colors
|
||||
{activeGroup === i
|
||||
? 'bg-amber-400 text-zinc-900'
|
||||
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200'}
|
||||
{currentGroup === i && activeGroup !== i ? 'ring-1 ring-amber-400/50' : ''}"
|
||||
>
|
||||
{groupLabel(i)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Jump-to-current banner ──────────────────────────────────────────── -->
|
||||
{#if data.lastChapter && data.lastChapter > 0 && !searchQuery && activeGroup !== currentGroup}
|
||||
<button
|
||||
onclick={() => (activeGroup = currentGroup)}
|
||||
class="flex items-center gap-2 w-full px-3 py-2 mb-3 rounded-lg bg-amber-400/10 border border-amber-400/25 text-amber-400 text-sm hover:bg-amber-400/20 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
Jump to Ch.{data.lastChapter}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- ── Chapter list ───────────────────────────────────────────────────── -->
|
||||
{#if visibleChapters.length === 0}
|
||||
{#if searchQuery}
|
||||
<p class="text-zinc-500 text-sm py-8 text-center">No chapters match "{searchQuery}"</p>
|
||||
{:else}
|
||||
<p class="text-zinc-500 text-sm">No chapters available yet.</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Result count while searching -->
|
||||
{#if searchQuery}
|
||||
<p class="text-xs text-zinc-500 mb-2">{visibleChapters.length} result{visibleChapters.length === 1 ? '' : 's'}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each visibleChapters as chapter}
|
||||
{@const isCurrent = data.lastChapter === chapter.number}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{chapter.number}"
|
||||
id="ch-{chapter.number}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded transition-colors group
|
||||
{isCurrent ? 'bg-zinc-800' : 'hover:bg-zinc-800/60'}"
|
||||
>
|
||||
<!-- Number badge -->
|
||||
<span
|
||||
class="w-9 text-right text-sm font-mono flex-shrink-0
|
||||
{isCurrent ? 'text-amber-400 font-semibold' : 'text-zinc-600'}"
|
||||
>
|
||||
{chapter.number}
|
||||
</span>
|
||||
|
||||
<!-- Title -->
|
||||
<span
|
||||
class="flex-1 min-w-0 text-sm truncate transition-colors
|
||||
{isCurrent ? 'text-amber-300 font-medium' : 'text-zinc-300 group-hover:text-zinc-100'}"
|
||||
>
|
||||
{chapter.title || `Chapter ${chapter.number}`}
|
||||
</span>
|
||||
|
||||
<!-- Date — desktop only -->
|
||||
{#if chapter.date_label}
|
||||
<span class="hidden sm:block text-xs text-zinc-600 flex-shrink-0">
|
||||
{chapter.date_label}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Reading indicator -->
|
||||
{#if isCurrent}
|
||||
<span class="text-xs text-amber-500 font-medium flex-shrink-0">reading</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Bottom page-group nav (mirrors top, for long lists) -->
|
||||
{#if !searchQuery && totalGroups > 1}
|
||||
<div class="flex flex-wrap gap-1.5 mt-5 pt-4 border-t border-zinc-800">
|
||||
{#each Array(totalGroups) as _, i}
|
||||
<button
|
||||
onclick={() => { activeGroup = i; window.scrollTo({ top: 0, behavior: 'smooth' }); }}
|
||||
class="px-2.5 py-1 rounded text-xs font-medium transition-colors
|
||||
{activeGroup === i
|
||||
? 'bg-amber-400 text-zinc-900'
|
||||
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200'}
|
||||
{currentGroup === i && activeGroup !== i ? 'ring-1 ring-amber-400/50' : ''}"
|
||||
>
|
||||
{groupLabel(i)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -114,7 +114,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
const res = await fetch(presignUrl);
|
||||
if (!res.ok) throw new Error(`MinIO returned ${res.status}`);
|
||||
const markdown = await res.text();
|
||||
html = await marked(markdown, { async: true });
|
||||
html = marked(markdown) as string;
|
||||
} catch (e) {
|
||||
// Don't hard-fail — show empty content with error message
|
||||
log.error('chapter', 'failed to fetch chapter content', { slug, n, err: String(e) });
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
import { onMount } from 'svelte';
|
||||
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { marked } from 'marked';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let html = $state(data.html);
|
||||
let fetchingContent = $state(!data.isPreview && !data.html);
|
||||
let fetchError = $state('');
|
||||
|
||||
// ── Word count ────────────────────────────────────────────────────────────
|
||||
function countWords(htmlStr: string | null): number {
|
||||
if (!htmlStr) return 0;
|
||||
@@ -15,10 +18,6 @@
|
||||
|
||||
const wordCount = $derived(countWords(html));
|
||||
|
||||
let html = $state(data.html);
|
||||
let fetchingContent = $state(!data.isPreview && !data.html);
|
||||
let fetchError = $state('');
|
||||
|
||||
onMount(async () => {
|
||||
// Record reading progress (skip for preview chapters)
|
||||
if (!data.isPreview) {
|
||||
@@ -42,6 +41,7 @@
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
const d = (await res.json()) as { text?: string };
|
||||
if (d.text) {
|
||||
const { marked } = await import('marked');
|
||||
html = await marked(d.text, { async: true });
|
||||
} else {
|
||||
fetchError = 'Chapter content not available.';
|
||||
|
||||
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 type { Actions, PageServerLoad } from './$types';
|
||||
import { changePassword, listUserSessions } from '$lib/server/pocketbase';
|
||||
import { changePassword, listUserSessions, getUserByUsername } from '$lib/server/pocketbase';
|
||||
import { presignAvatarUrl } from '$lib/server/minio';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
@@ -15,8 +16,20 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) });
|
||||
}
|
||||
|
||||
// Fetch avatar presigned URL if user has one
|
||||
let avatarUrl: string | null = null;
|
||||
try {
|
||||
const record = await getUserByUsername(locals.user.username);
|
||||
if (record?.avatar_url) {
|
||||
avatarUrl = await presignAvatarUrl(locals.user.id);
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(e) });
|
||||
}
|
||||
|
||||
return {
|
||||
user: locals.user,
|
||||
avatarUrl,
|
||||
sessions: sessions.map((s) => ({
|
||||
id: s.id,
|
||||
user_agent: s.user_agent,
|
||||
|
||||
@@ -3,9 +3,81 @@
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
// ── Avatar ───────────────────────────────────────────────────────────────────
|
||||
let avatarUrl = $state<string | null>(data.avatarUrl ?? null);
|
||||
let avatarUploading = $state(false);
|
||||
let avatarError = $state('');
|
||||
let fileInput: HTMLInputElement | null = null;
|
||||
|
||||
// Crop modal state
|
||||
let cropFile = $state<File | null>(null);
|
||||
|
||||
function handleAvatarChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
// Reset input so the same file can be re-selected after cancel
|
||||
if (fileInput) fileInput.value = '';
|
||||
cropFile = file;
|
||||
}
|
||||
|
||||
async function handleCropConfirm(blob: Blob, mimeType: string) {
|
||||
cropFile = null;
|
||||
avatarUploading = true;
|
||||
avatarError = '';
|
||||
try {
|
||||
// Step 1: get presigned PUT URL
|
||||
const presignRes = await fetch('/api/profile/avatar', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mime_type: mimeType })
|
||||
});
|
||||
if (!presignRes.ok) {
|
||||
const body = await presignRes.json().catch(() => ({})) as { message?: string };
|
||||
avatarError = body.message ?? `Failed to prepare upload (${presignRes.status})`;
|
||||
return;
|
||||
}
|
||||
const { upload_url, key } = await presignRes.json() as { upload_url: string; key: string };
|
||||
|
||||
// Step 2: PUT blob directly to MinIO
|
||||
const putRes = await fetch(upload_url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': mimeType },
|
||||
body: blob
|
||||
});
|
||||
if (!putRes.ok) {
|
||||
avatarError = `Upload failed (${putRes.status})`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: record key in PocketBase and get fresh presigned GET URL
|
||||
const patchRes = await fetch('/api/profile/avatar', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key })
|
||||
});
|
||||
if (!patchRes.ok) {
|
||||
const body = await patchRes.json().catch(() => ({})) as { message?: string };
|
||||
avatarError = body.message ?? `Failed to save avatar (${patchRes.status})`;
|
||||
return;
|
||||
}
|
||||
const result = await patchRes.json() as { avatar_url: string | null };
|
||||
avatarUrl = result.avatar_url;
|
||||
} catch {
|
||||
avatarError = 'Network error during upload';
|
||||
} finally {
|
||||
avatarUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCropCancel() {
|
||||
cropFile = null;
|
||||
}
|
||||
|
||||
// ── Settings ────────────────────────────────────────────────────────────────
|
||||
let voices = $state<string[]>([]);
|
||||
let voicesLoaded = $state(false);
|
||||
@@ -141,13 +213,71 @@
|
||||
<title>Profile — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if cropFile && browser}
|
||||
{#await import('$lib/components/AvatarCropModal.svelte') then { default: AvatarCropModal }}
|
||||
<AvatarCropModal
|
||||
file={cropFile}
|
||||
onconfirm={handleCropConfirm}
|
||||
oncancel={handleCropCancel}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
<!-- Hidden logout form used when user ends their own session -->
|
||||
<form id="logout-form" method="POST" action="/logout" class="hidden"></form>
|
||||
|
||||
<div class="max-w-xl mx-auto space-y-10">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-zinc-100">Profile</h1>
|
||||
<p class="text-zinc-400 text-sm mt-1">Signed in as <span class="text-zinc-200 font-medium">{data.user.username}</span></p>
|
||||
<div class="flex items-center gap-5">
|
||||
<!-- Avatar -->
|
||||
<div class="relative shrink-0">
|
||||
<button
|
||||
onclick={() => fileInput?.click()}
|
||||
class="group relative w-20 h-20 rounded-full overflow-hidden ring-2 ring-zinc-600 hover:ring-amber-400 transition-all focus:outline-none focus:ring-amber-400"
|
||||
title="Change profile picture"
|
||||
disabled={avatarUploading}
|
||||
>
|
||||
{#if avatarUrl}
|
||||
<img src={avatarUrl} alt="Profile" class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-zinc-700 flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-zinc-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Hover overlay -->
|
||||
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{#if avatarUploading}
|
||||
<svg class="w-5 h-5 text-white animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
class="hidden"
|
||||
onchange={handleAvatarChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-zinc-100">{data.user.username}</h1>
|
||||
<p class="text-zinc-400 text-sm mt-0.5 capitalize">{data.user.role}</p>
|
||||
{#if avatarError}
|
||||
<p class="text-red-400 text-xs mt-1">{avatarError}</p>
|
||||
{:else}
|
||||
<p class="text-zinc-500 text-xs mt-1">Click avatar to change photo</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Reading settings ─────────────────────────────────────────────────── -->
|
||||
|
||||
59
ui/src/routes/users/[username]/+page.server.ts
Normal file
59
ui/src/routes/users/[username]/+page.server.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import {
|
||||
getPublicProfile,
|
||||
getSubscription,
|
||||
getUserPublicLibrary,
|
||||
getUserCurrentlyReading
|
||||
} from '$lib/server/pocketbase';
|
||||
import { presignAvatarUrl } from '$lib/server/minio';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
const { username } = params;
|
||||
|
||||
const profile = await getPublicProfile(username).catch(() => null);
|
||||
if (!profile) error(404, `User "${username}" not found`);
|
||||
|
||||
// Resolve avatar
|
||||
let avatarUrl: string | null = null;
|
||||
if (profile.avatar_url) {
|
||||
avatarUrl = await presignAvatarUrl(profile.id).catch(() => null);
|
||||
}
|
||||
|
||||
// Subscription state for the logged-in visitor
|
||||
let isSubscribed = false;
|
||||
const isSelf = locals.user?.id === profile.id;
|
||||
if (locals.user && !isSelf) {
|
||||
const sub = await getSubscription(locals.user.id, profile.id).catch(() => null);
|
||||
isSubscribed = !!sub;
|
||||
}
|
||||
|
||||
// Load public library + currently reading in parallel
|
||||
const [library, currentlyReading] = await Promise.all([
|
||||
getUserPublicLibrary(profile.id).catch((e) => {
|
||||
log.error('users/profile', 'getUserPublicLibrary failed', { username, err: String(e) });
|
||||
return [] as Awaited<ReturnType<typeof getUserPublicLibrary>>;
|
||||
}),
|
||||
getUserCurrentlyReading(profile.id).catch((e) => {
|
||||
log.error('users/profile', 'getUserCurrentlyReading failed', { username, err: String(e) });
|
||||
return [] as Awaited<ReturnType<typeof getUserCurrentlyReading>>;
|
||||
})
|
||||
]);
|
||||
|
||||
return {
|
||||
profile: {
|
||||
id: profile.id,
|
||||
username: profile.username,
|
||||
created: profile.created,
|
||||
followerCount: profile.followerCount,
|
||||
followingCount: profile.followingCount
|
||||
},
|
||||
avatarUrl,
|
||||
isSubscribed,
|
||||
isSelf,
|
||||
isLoggedIn: !!locals.user,
|
||||
library,
|
||||
currentlyReading
|
||||
};
|
||||
};
|
||||
224
ui/src/routes/users/[username]/+page.svelte
Normal file
224
ui/src/routes/users/[username]/+page.svelte
Normal file
@@ -0,0 +1,224 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// ── Subscribe / unsubscribe ──────────────────────────────────────────────────
|
||||
let subscribed = $state(data.isSubscribed);
|
||||
let followerCount = $state(data.profile.followerCount);
|
||||
let subLoading = $state(false);
|
||||
|
||||
async function toggleSubscribe() {
|
||||
if (subLoading) return;
|
||||
subLoading = true;
|
||||
try {
|
||||
const method = subscribed ? 'DELETE' : 'POST';
|
||||
const res = await fetch(`/api/users/${data.profile.username}/subscribe`, { method });
|
||||
if (res.ok) {
|
||||
subscribed = !subscribed;
|
||||
followerCount += subscribed ? 1 : -1;
|
||||
}
|
||||
} finally {
|
||||
subLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function initials(username: string): string {
|
||||
return username.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function joinDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'long' });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function parseGenres(genres: string[] | string | null | undefined): string[] {
|
||||
if (!genres) return [];
|
||||
if (Array.isArray(genres)) return genres;
|
||||
try {
|
||||
const parsed = JSON.parse(genres);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.profile.username} — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- ── Header ────────────────────────────────────────────────────────────── -->
|
||||
<div class="flex items-start gap-5 mb-8">
|
||||
<!-- Avatar -->
|
||||
<div class="flex-shrink-0">
|
||||
{#if data.avatarUrl}
|
||||
<img
|
||||
src={data.avatarUrl}
|
||||
alt={data.profile.username}
|
||||
class="w-20 h-20 rounded-full object-cover ring-2 ring-zinc-700"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-20 h-20 rounded-full bg-zinc-700 flex items-center justify-center text-2xl font-bold text-zinc-300 ring-2 ring-zinc-600">
|
||||
{initials(data.profile.username)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-xl font-bold text-zinc-100 mb-0.5">{data.profile.username}</h1>
|
||||
<p class="text-xs text-zinc-500 mb-3">Joined {joinDate(data.profile.created)}</p>
|
||||
|
||||
<!-- Stats row -->
|
||||
<div class="flex gap-5 text-sm mb-4">
|
||||
<span>
|
||||
<span class="font-semibold text-zinc-100">{followerCount}</span>
|
||||
<span class="text-zinc-500 ml-1">followers</span>
|
||||
</span>
|
||||
<span>
|
||||
<span class="font-semibold text-zinc-100">{data.profile.followingCount}</span>
|
||||
<span class="text-zinc-500 ml-1">following</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Subscribe button — only shown to logged-in visitors viewing someone else's profile -->
|
||||
{#if data.isLoggedIn && !data.isSelf}
|
||||
<button
|
||||
onclick={toggleSubscribe}
|
||||
disabled={subLoading}
|
||||
class="px-4 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50
|
||||
{subscribed
|
||||
? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600 border border-zinc-600'
|
||||
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300'}"
|
||||
>
|
||||
{#if subLoading}
|
||||
…
|
||||
{:else if subscribed}
|
||||
Following
|
||||
{:else}
|
||||
Follow
|
||||
{/if}
|
||||
</button>
|
||||
{:else if !data.isLoggedIn}
|
||||
<a
|
||||
href="/login"
|
||||
class="inline-block px-4 py-1.5 rounded-lg text-sm font-medium bg-amber-400 text-zinc-900 hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Follow
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Currently Reading ─────────────────────────────────────────────────── -->
|
||||
{#if data.currentlyReading.length > 0}
|
||||
<section class="mb-10">
|
||||
<h2 class="text-base font-semibold text-zinc-200 mb-3">Currently Reading</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each data.currentlyReading as { book, chapter }}
|
||||
<a
|
||||
href="/books/{book.slug}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
|
||||
>
|
||||
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden relative">
|
||||
{#if book.cover}
|
||||
<img
|
||||
src={book.cover}
|
||||
alt={book.title}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-zinc-600">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="absolute bottom-1.5 right-1.5 text-xs bg-amber-400 text-zinc-900 font-bold px-1.5 py-0.5 rounded">
|
||||
ch.{chapter}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title}</h3>
|
||||
{#if book.author}
|
||||
<p class="text-xs text-zinc-500 truncate mt-0.5">{book.author}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Library ───────────────────────────────────────────────────────────── -->
|
||||
{#if data.library.length > 0}
|
||||
<section class="mb-10">
|
||||
<h2 class="text-base font-semibold text-zinc-200 mb-3">
|
||||
Library
|
||||
<span class="text-zinc-500 font-normal text-sm ml-1">({data.library.length})</span>
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each data.library as { book, chapter, saved }}
|
||||
{@const genres = parseGenres(book.genres)}
|
||||
<a
|
||||
href="/books/{book.slug}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
|
||||
>
|
||||
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden relative">
|
||||
{#if book.cover}
|
||||
<img
|
||||
src={book.cover}
|
||||
alt={book.title}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-zinc-600">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
{#if chapter}
|
||||
<span class="absolute bottom-1.5 right-1.5 text-xs bg-zinc-900/80 text-zinc-300 font-medium px-1.5 py-0.5 rounded">
|
||||
ch.{chapter}
|
||||
</span>
|
||||
{/if}
|
||||
{#if saved && !chapter}
|
||||
<span class="absolute top-1.5 right-1.5">
|
||||
<svg class="w-3.5 h-3.5 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M5 3a2 2 0 00-2 2v16l9-4 9 4V5a2 2 0 00-2-2H5z"/>
|
||||
</svg>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title}</h3>
|
||||
{#if book.author}
|
||||
<p class="text-xs text-zinc-500 truncate mt-0.5">{book.author}</p>
|
||||
{/if}
|
||||
{#if genres.length > 0}
|
||||
<p class="text-xs text-zinc-600 truncate mt-0.5">{genres[0]}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Empty state ───────────────────────────────────────────────────────── -->
|
||||
{#if data.library.length === 0 && data.currentlyReading.length === 0}
|
||||
<div class="py-16 text-center text-zinc-500">
|
||||
<svg class="w-10 h-10 mx-auto mb-3 text-zinc-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
<p class="text-sm">No books in library yet.</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -3,5 +3,11 @@ import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()]
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
ssr: {
|
||||
// Force these packages to be bundled into the server output rather than
|
||||
// treated as external requires. The production Docker image has no
|
||||
// node_modules, so anything used in server-side code must be inlined.
|
||||
noExternal: ['marked']
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user